diff --git a/.github/.gitkeep b/.github/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.github/ISSUE_TEMPLATE/.gitkeep b/.github/ISSUE_TEMPLATE/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..4d32935 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,46 @@ +name: Bug report +description: Report a reproducible IronLog problem +title: "[Bug]: " +labels: [bug] +body: + - type: markdown + attributes: + value: Please remove personal fitness data, photos, API keys, and signing information from every attachment. + - type: input + id: build + attributes: + label: Build or commit + placeholder: Version name, commit SHA, or build date + validations: + required: true + - type: input + id: device + attributes: + label: Device and Android version + placeholder: Pixel 7, Android 16 + validations: + required: true + - type: textarea + id: steps + attributes: + label: Reproduction steps + placeholder: Tell us exactly what you did and what happened. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Screenshots or sanitized logs + - type: checkboxes + id: privacy + attributes: + label: Privacy check + options: + - label: I removed personal data, API keys, and signing information. + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..98499d1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,42 @@ +name: Feature request +description: Propose a focused improvement to IronLog +title: "[Feature]: " +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Training problem + description: What becomes difficult during or around a real workout? + validations: + required: true + - type: textarea + id: outcome + attributes: + label: Desired outcome + description: Describe the result, not only a UI control. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: dropdown + id: area + attributes: + label: Area + options: + [ + Workout logging, + Plans, + Recovery, + Progress and stats, + Iron Ledger, + Widgets, + Data portability, + Health Connect, + Intelligence, + Other, + ] + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b663c1f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +## Outcome + +What user-visible problem does this change solve? + +## Changes + +- Describe the most important implementation change. + +## Verification + +- [ ] Focused tests pass +- [ ] `testDebugUnitTest` passes +- [ ] `lintDebug` passes +- [ ] `assembleDebug` passes +- [ ] Visual or device checks are attached when relevant +- [ ] No keys, keystores, personal data, databases, photos, or build outputs are included + +## Risk + +Call out persistence, migration, lifecycle, notification, camera, Health Connect, import/export, widget, and signing implications. diff --git a/.github/assets/ironlog-hero.svg b/.github/assets/ironlog-hero.svg new file mode 100644 index 0000000..c88881d --- /dev/null +++ b/.github/assets/ironlog-hero.svg @@ -0,0 +1,52 @@ + + IronLog — Train. Recover. Prove it. + An animated training instrument moves a forge-orange signal through a barbell, recovery ring, and completed ledger mark. + + + + + LOCAL-FIRST ANDROID TRAINING + IRONLOG + Train. Recover. Prove it. + Fast logging, honest recovery, durable records. + + LOG + + RECOVER + + PROGRESS + + + + + + + + + + + + + SESSION + READY + + + THE WORK IS THE RECORD + + diff --git a/.github/assets/screens/home.png b/.github/assets/screens/home.png new file mode 100644 index 0000000..b6cf102 Binary files /dev/null and b/.github/assets/screens/home.png differ diff --git a/.github/assets/screens/recovery.png b/.github/assets/screens/recovery.png new file mode 100644 index 0000000..c7c9e7c Binary files /dev/null and b/.github/assets/screens/recovery.png differ diff --git a/.github/assets/training-loop.svg b/.github/assets/training-loop.svg new file mode 100644 index 0000000..1306c9c --- /dev/null +++ b/.github/assets/training-loop.svg @@ -0,0 +1,26 @@ + + The IronLog training loop + A signal moves from workout logging to recovery, the Iron Ledger, and the next session. + + + One continuous training record + The next decision is informed by the work already done. + + + + LOG THE SETload · reps · effort + READ RECOVERYmuscles · sleep · readiness + EARN THE LEDGERstreaks · PRs · milestones + GO AGAINplan · adjust · progress + + diff --git a/.github/workflows/.gitkeep b/.github/workflows/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..16e0514 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,37 @@ +name: Android CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: android-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out source + uses: actions/checkout@v7 + + - name: Set up Java 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Verify wrapper permissions + run: chmod +x gradlew + + - name: Test, lint, and assemble debug + run: ./gradlew testDebugUnitTest lintDebug assembleDebug --no-daemon diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b2af1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Gradle +.gradle/ +build/ +app/build/ +**/build/ + +# Kotlin compiler +.kotlin/ + +# Local config +local.properties + +# IDE +.idea/ +*.iml + +# Keystore (don't commit secrets) +*.jks + +# OS +.DS_Store +Thumbs.db + +# Local agent/design working files +.agents/ +.impeccable/ +artifacts/ +PROJECT.md diff --git a/.nojekyll b/.nojekyll deleted file mode 100644 index 8b13789..0000000 --- a/.nojekyll +++ /dev/null @@ -1 +0,0 @@ - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3900ca5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,26 @@ +# Contributing to IronLog + +Thanks for helping improve IronLog. Changes should preserve three properties: workout logging remains fast, the primary record remains trustworthy, and optional integrations do not become requirements for basic use. + +## Before opening a change + +1. Search existing issues and pull requests. +2. Keep the change focused and explain the user-visible outcome. +3. Do not commit API keys, `local.properties`, keystores, fitness exports, progress photos, or generated build output. +4. Add or update tests for behavioral changes. + +## Local checks + +Run the relevant focused tests while developing, then run the full gate before opening a pull request: + +```bash +./gradlew testDebugUnitTest lintDebug assembleDebug +``` + +Use `gradlew.bat` on Windows. Release builds require your own local signing material and are not part of the public CI gate. + +## Pull requests + +Describe the problem, the chosen behavior, risk areas, and how you verified it. Include screenshots or a short recording for visual changes. Body-map, camera, widget, notification, Health Connect, and lifecycle changes should be checked on a real device when possible. + +The repository uses a personal-use source license. Opening a contribution does not change the project license or grant rights to the IronLog name, store identity, or signing keys. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..b3ddfc4 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,40 @@ +--- +scope: github-and-product-brand +source: PRODUCT.md and app/src/main/java/com/ironlog/app/ui/theme/IronLogThemes.kt +status: active +--- + +# IronLog design system + +## 1. Direction + +IronLog should read like a precise training instrument: dark, direct, and energetic at the moment of action. The visual signature is a forge-orange signal moving through a restrained black-and-steel environment. Recovery green and earned gold appear only when they carry meaning. + +## 2. Color + +| Role | Dark | Light | Use | +|---|---:|---:|---| +| Background | `#000000` | `#F6F7F8` | Main field | +| Surface | `#0D0D0D` | `#FFFFFF` | Raised information | +| Border | `#252525` | `#D8DCE1` | Structure | +| Primary text | `#FFFFFF` | `#111111` | Headlines and values | +| Secondary text | `#A8A8A8` | `#525861` | Supporting copy | +| Forge | `#FF4500` | `#D93600` | Primary action and motion | +| Recovery | `#00C170` | `#087F4D` | Ready/complete states | +| Earned | `#FFD700` | `#9A6A00` | Milestones only | + +## 3. Typography + +Use Lexend in the Android product where bundled and system sans-serif in repository graphics. Headlines are heavy and compact; metrics are large with tabular numerals; explanatory text stays regular and readable. All-caps labels are reserved for short operational signals, with visible tracking. + +## 4. Shape and layout + +Prefer one dominant composition over repeated feature cards. Use 10–18 px radii for product surfaces, 1 px structural borders, and square or pill forms only when they communicate a control or status. Keep the GitHub hero editorial: wordmark and promise on one side, a single animated training instrument on the other. + +## 5. Motion + +Motion must explain a training loop: load, effort, recovery, record, repeat. Use simple transforms, stroke dashes, and opacity changes; avoid elastic motion and perpetual high-frequency effects. Typical duration is 1.8–6 seconds, with slow ambient rotations no faster than 12 seconds. Under `prefers-reduced-motion`, disable every animation and render the meaningful final state. + +## 6. Usage rules + +Do not use gradients as decoration, glass effects, emoji icons, stock gym photography, or hand-drawn pseudo-product art. Do not make orange the background of large text areas. Claims in README graphics must match implemented code and current validation. SVGs need semantic titles/descriptions, adaptive colors, unique IDs, and successful XML/render checks before publication. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..6150542 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,48 @@ +--- +type: brand +audience: lifters who want fast, trustworthy workout tracking without making cloud accounts or subscriptions the center of the experience +status: active +--- + +# IronLog + +## Purpose + +IronLog is a local-first Android strength-training companion. It helps people record a session quickly, understand recovery, follow a plan, and keep an honest record of their work. The product should feel useful between sets, not like a social feed or a spreadsheet squeezed onto a phone. + +## Promise + +Train. Recover. Prove it. + +IronLog keeps the primary workout record on the device, makes backup and export explicit, and treats intelligence as an optional aid rather than a requirement for basic use. + +## Personality + +- Disciplined: clear priorities, precise metrics, no ornamental noise. +- Candid: earned progress is more important than inflated motivation. +- Forged: dark, tactile, athletic, and durable without becoming aggressive. +- Capable: powerful features remain understandable during a real workout. + +## Visual language + +The core identity uses near-black surfaces, hot forge orange, white type, recovery green, and sparing gold. The interface uses strong numeric hierarchy, compact labels, firm borders, and generous breathing room around primary actions. Motion should describe state, progress, or flow; it should never become ambient distraction. + +## Content principles + +- Lead with the action or outcome. +- Use plain training language. +- Make privacy and storage behavior explicit. +- Separate shipped capabilities from future plans. +- Avoid hype, fake precision, and claims that depend on unavailable services. + +## Anti-references + +- Generic neon-gym marketing and smoky bodybuilding imagery. +- SaaS card grids that give every feature equal weight. +- Cloud-first language for a product whose core works locally. +- Gamification detached from completed training. +- Decorative animation that ignores reduced-motion preferences. + +## Accessibility + +Public-facing graphics must remain legible in light and dark GitHub themes, preserve useful contrast, avoid communicating status with color alone, and provide a static state when `prefers-reduced-motion` is enabled. diff --git a/README.md b/README.md new file mode 100644 index 0000000..61c39e7 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +
+ +![IronLog — Train. Recover. Prove it.](.github/assets/ironlog-hero.svg) + +[![Android CI](https://github.com/Yannam-Builds/Ironlog/actions/workflows/android.yml/badge.svg)](https://github.com/Yannam-Builds/Ironlog/actions/workflows/android.yml) +![Android 8+](https://img.shields.io/badge/Android-8.0%2B-00C170?logo=android&logoColor=white) +![Kotlin](https://img.shields.io/badge/Kotlin-2.1-7F52FF?logo=kotlin&logoColor=white) +![Local first](https://img.shields.io/badge/data-local--first-FF4500) +[![License](https://img.shields.io/badge/license-personal%20use-555)](LICENSE) + +**A native Android strength-training companion for fast logging, recovery awareness, plans, and an honest record of the work.** + +[Features](#built-for-the-work-between-sets) · [Screens](#the-current-build) · [Architecture](#how-it-is-built) · [Build](#build-it-locally) · [Contributing](CONTRIBUTING.md) + +
+ +> [!NOTE] +> IronLog is under active development and physical-device QA. The signed build is not distributed from this repository yet; build locally or follow a future tagged release. + +## Built for the work between sets + +IronLog keeps the main training loop fast and keeps the primary record on your device. + +- **Log without friction.** Active sessions support sets, load, reps, timers, exercise substitutions, progression suggestions, and resumable foreground tracking. +- **See recovery, not just history.** Muscle readiness, sleep and biometric inputs, manual check-ins, and Health Connect can inform what to train next. +- **Work from a plan.** Build programs, choose starter plans, save sessions back to plans, or share compatible plans through bounded QR payloads. +- **Keep an honest ledger.** Streaks, XP, personal records, milestones, and Forge Fox widgets are derived from completed work. +- **Own the data.** ObjectBox stores the core record locally. Explicit IronLog backup, restore, import, and export remain available; automatic Android backup is disabled for sensitive fitness and photo data. +- **Choose the intelligence.** On-device and user-configured cloud AI paths are optional. The workout logger does not require an AI provider. + +![Animated diagram of the IronLog training loop](.github/assets/training-loop.svg) + +## The current build + + + + + + + + + + +
IronLog home screen showing the next workout and recovery overviewIronLog recovery map with the front body view centered below the controls
Training command centerMuscle recovery map
+ +The July 2026 stabilization pass includes API 36 targeting, transactional workout and import writes, hardened backup/import behavior, corrected readiness and streak clocks, bounded QR decoding, centered recovery-map transforms, and release builds verified on an API 36.1 emulator and a physical Android device. + +## How it is built + +```mermaid +flowchart TD + UI["Jetpack Compose UI"] --> VM["ViewModels and UI state"] + VM --> DOMAIN["Training, recovery, and ledger engines"] + DOMAIN --> REPOS["Repositories"] + REPOS --> DB[(ObjectBox)] + REPOS --> HC["Health Connect"] + DOMAIN --> AI["Optional local or cloud AI"] + REPOS --> PORT["Backup, import, and export"] + DB --> WIDGETS["Glance widgets and workers"] +``` + +The app is Kotlin-first and uses Jetpack Compose, ObjectBox, WorkManager, Health Connect, CameraX/ML Kit, Jetpack Glance, Vico, Coil, and Ktor. Java 17 is required for the Android build. + +## Build it locally + +Requirements: Android Studio with Android SDK 36, JDK 17, and an Android 8.0+ device or emulator. + +```bash +git clone https://github.com/Yannam-Builds/Ironlog.git +cd Ironlog +./gradlew testDebugUnitTest lintDebug assembleDebug +``` + +On Windows, use `gradlew.bat` instead of `./gradlew`. + +Release signing is intentionally local. Create `local.properties` with your own version and signing values and provide your own `app/ironlog-release.jks`; never reuse project-owner signing material. + +```properties +version.code=1 +version.name=1.0.0 +signing.storePassword=your-store-password +signing.keyPassword=your-key-password +``` + +## Verification status + +- `:app:lintDebug` passes with no errors. +- 134 JVM tests pass. +- Signed, minified APK and AAB builds pass with the private local signing configuration. +- Fresh onboarding, starter-plan selection, Home, Recovery Map front/back views, muscle hit testing, and landscape recreation were smoke-tested on an API 36.1 Pixel 7 emulator. +- The latest stable signed APK was installed on a physical device with app data preserved. + +Physical-device coverage is still being expanded. See [Security](SECURITY.md) for private vulnerability reporting and [Contributing](CONTRIBUTING.md) before proposing a change. + +## Project status + +Current priorities are broader physical-device regression testing, signing-key rotation before public store distribution, and final distribution infrastructure. The dated [codebase review](docs/CODEBASE_REVIEW_2026-07-27.md) separates current work from historical implementation notes under `docs/superpowers/`. + +--- + +
+ Train. Recover. Prove it.
+ Built for the record you earn. +
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f12a51c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +# Security policy + +## Reporting a vulnerability + +Do not open a public issue for a vulnerability that could expose fitness data, progress photos, backup contents, API keys, signing material, or another user's device data. + +Email **ironlogsupport@gmail.com** with: + +- the affected version or commit; +- a concise reproduction; +- the likely impact; +- any logs or proof of concept with personal data removed. + +You should receive an acknowledgement within seven days. Please allow time for validation and a coordinated fix before publishing details. + +## Supported code + +Security fixes target the current default branch and the latest published build when one exists. Historical branches and untagged artifacts are not supported releases. + +## Sensitive files + +Release keystores, `local.properties`, provider API keys, user exports, databases, progress photos, and device logs must never be committed. Contributors must use their own signing material for local release builds. diff --git a/android/.gitkeep b/android/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/.gitkeep b/android/app/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/.gitkeep b/android/app/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/debug/.gitkeep b/android/app/src/debug/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/debugOptimized/.gitkeep b/android/app/src/debugOptimized/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/.gitkeep b/android/app/src/main/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/assets/.gitkeep b/android/app/src/main/assets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/assets/fonts/.gitkeep b/android/app/src/main/assets/fonts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/java/.gitkeep b/android/app/src/main/java/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/java/com/.gitkeep b/android/app/src/main/java/com/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/java/com/ironlog/.gitkeep b/android/app/src/main/java/com/ironlog/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/java/com/ironlog/app/.gitkeep b/android/app/src/main/java/com/ironlog/app/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/java/com/ironlog/app/backup/.gitkeep b/android/app/src/main/java/com/ironlog/app/backup/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/java/com/ironlog/app/config/.gitkeep b/android/app/src/main/java/com/ironlog/app/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/java/com/ironlog/app/migration/.gitkeep b/android/app/src/main/java/com/ironlog/app/migration/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/.gitkeep b/android/app/src/main/res/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/drawable-hdpi/.gitkeep b/android/app/src/main/res/drawable-hdpi/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/drawable-mdpi/.gitkeep b/android/app/src/main/res/drawable-mdpi/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/drawable-xhdpi/.gitkeep b/android/app/src/main/res/drawable-xhdpi/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/drawable-xxhdpi/.gitkeep b/android/app/src/main/res/drawable-xxhdpi/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/drawable-xxxhdpi/.gitkeep b/android/app/src/main/res/drawable-xxxhdpi/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/drawable/.gitkeep b/android/app/src/main/res/drawable/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/mipmap-anydpi-v26/.gitkeep b/android/app/src/main/res/mipmap-anydpi-v26/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/mipmap-hdpi/.gitkeep b/android/app/src/main/res/mipmap-hdpi/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/mipmap-mdpi/.gitkeep b/android/app/src/main/res/mipmap-mdpi/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/mipmap-xhdpi/.gitkeep b/android/app/src/main/res/mipmap-xhdpi/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/mipmap-xxhdpi/.gitkeep b/android/app/src/main/res/mipmap-xxhdpi/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/.gitkeep b/android/app/src/main/res/mipmap-xxxhdpi/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/values-night/.gitkeep b/android/app/src/main/res/values-night/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/values-v31/.gitkeep b/android/app/src/main/res/values-v31/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/values/.gitkeep b/android/app/src/main/res/values/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/xml/.gitkeep b/android/app/src/main/res/xml/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/gradle/.gitkeep b/android/gradle/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/gradle/wrapper/.gitkeep b/android/gradle/wrapper/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app-demo.js b/app-demo.js deleted file mode 100644 index c3a2b7f..0000000 --- a/app-demo.js +++ /dev/null @@ -1,301 +0,0 @@ -const demoState = { - tab: "home", - readiness: 92, - streak: 7, - xp: 84, - level: 8, - grade: "Iron", - theme: "amethyst", -}; - -const foxAssets = { - determined: "assets/site/chibi_determined.webp", - proud: "assets/site/chibi_proud.webp", - dumbbell: "assets/site/chibi_dumbbell.webp", - streak: "assets/site/chibi_streak.webp", - watch: "assets/site/chibi_watch.webp", -}; - -const screens = { - home: { - kicker: "Daily proof", - title: "Ready to train", - metricTitle: "Fresh", - metricCopy: "Push volume today. Ledger proof is available.", - fox: "determined", - render: () => ` -
-

Recommended plan

-

Upper strength block. Bench press, weighted pullups, row volume.

-
-
-
-
-

${demoState.streak} day streak

-

Proof logged before midnight keeps the chain alive.

-
-
-

Level ${demoState.level}

-

${demoState.grade} candidate. ${demoState.xp}% to next gate.

-
-
-
-

Muscle recovery

-

Chest and back are above 90%. Legs are maintaining.

-
-
- `, - }, - log: { - kicker: "Workout log", - title: "Upper strength", - metricTitle: "Set quality", - metricCopy: "Every completed set updates proof, XP, and recovery.", - fox: "dumbbell", - render: () => ` -
-

Bench press

-
Set 165 kg x 8Done
-
Set 267.5 kg x 6Next
- -
-
-

Progression

-

Recommended next target: add 2.5 kg if RPE stays below 8.

-
-
- `, - }, - recovery: { - kicker: "Recovery map", - title: "Train or recover", - metricTitle: "Readiness", - metricCopy: "Move the slider to preview how the app reacts to recovery state.", - fox: "watch", - render: () => ` -
-

Manual check-in

-

Energy, soreness, and sleep adjust the recommendation.

-
- - ${demoState.readiness} -
-
-
-
- Front body recovery map -
Front
-
-
- Back body recovery map -
Back
-
-
- `, - }, - ledger: { - kicker: "Iron Ledger", - title: "Proof trail", - metricTitle: `${demoState.grade} grade`, - metricCopy: "Rank, badges, and integrity follow verified training history.", - fox: "proud", - render: () => ` -
- Iron grade badge -
-

Level ${demoState.level}

-

Integrity 100%. ${demoState.xp}% through the current gate.

-
-
-
-
-

Training signals

- ${["STR", "PWR", "HYP", "END"].map((signal, index) => ` -
- ${signal} -
- ${demoState.level + index} -
- `).join("")} -
- `, - }, -}; - -const els = { - body: document.body, - content: document.querySelector("[data-demo-content]"), - screenTitle: document.querySelector("[data-screen-title]"), - screenKicker: document.querySelector("[data-screen-kicker]"), - screenFox: document.querySelector("[data-screen-fox]"), - readiness: document.querySelector("[data-readiness]"), - metricTitle: document.querySelector("[data-metric-title]"), - metricCopy: document.querySelector("[data-metric-copy]"), - tabs: [...document.querySelectorAll("[data-demo-tab]")], - themeButtons: [...document.querySelectorAll("[data-theme-choice]")], -}; - -function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); -} - -function setTab(tab) { - demoState.tab = tab; - els.tabs.forEach((button) => button.classList.toggle("is-active", button.dataset.demoTab === tab)); - renderDemo(true); -} - -function setFox(assetKey) { - if (!els.screenFox) return; - - els.screenFox.src = foxAssets[assetKey] || foxAssets.determined; - els.screenFox.classList.remove("is-jumping"); - requestAnimationFrame(() => { - els.screenFox.classList.add("is-jumping"); - window.setTimeout(() => els.screenFox.classList.remove("is-jumping"), 430); - }); -} - -function renderDemo(animate = false) { - const screen = screens[demoState.tab]; - if (!screen || !els.content) return; - - const doRender = () => { - els.screenKicker.textContent = screen.kicker; - els.screenTitle.textContent = screen.title; - els.readiness.textContent = String(demoState.readiness); - els.metricTitle.textContent = screen.metricTitle; - els.metricCopy.textContent = screen.metricCopy; - els.content.innerHTML = screen.render(); - setFox(screen.fox); - bindInlineControls(); - }; - - if (!animate) { - doRender(); - return; - } - - els.content.classList.add("is-swapping"); - window.setTimeout(() => { - doRender(); - els.content.classList.remove("is-swapping"); - }, 150); -} - -function completeSet() { - demoState.xp = clamp(demoState.xp + 6, 0, 100); - demoState.readiness = clamp(demoState.readiness - 3, 45, 99); - if (demoState.xp >= 100) { - demoState.level += 1; - demoState.xp = 18; - demoState.grade = demoState.level >= 10 ? "Steel" : "Iron"; - setTab("ledger"); - return; - } - renderDemo(true); -} - -function bindInlineControls() { - const inlineComplete = document.querySelector("[data-inline-complete]"); - if (inlineComplete) { - inlineComplete.addEventListener("click", completeSet); - } - - const slider = document.querySelector("[data-readiness-slider]"); - const sliderValue = document.querySelector("[data-slider-value]"); - if (slider && sliderValue) { - slider.addEventListener("input", (event) => { - demoState.readiness = Number(event.target.value); - sliderValue.textContent = String(demoState.readiness); - els.readiness.textContent = String(demoState.readiness); - els.metricTitle.textContent = demoState.readiness >= 82 ? "Fresh" : demoState.readiness >= 66 ? "Maintain" : "Back off"; - els.metricCopy.textContent = demoState.readiness >= 82 - ? "Good window for heavier work." - : demoState.readiness >= 66 - ? "Keep quality high and volume controlled." - : "Recovery circuit is the better move."; - }); - } -} - -function revealVisibleSections() { - document.querySelectorAll(".reveal").forEach((element) => element.classList.add("is-visible")); -} - -function setupRevealObserver() { - const revealElements = [...document.querySelectorAll(".reveal")]; - - if (!revealElements.length) return; - - if (!("IntersectionObserver" in window)) { - revealVisibleSections(); - return; - } - - const revealObserver = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - entry.target.classList.add("is-visible"); - revealObserver.unobserve(entry.target); - } - }); - }, - { threshold: 0.18 } - ); - - revealElements.forEach((element) => revealObserver.observe(element)); -} - -document.querySelectorAll("[data-demo-tab]").forEach((button) => { - button.addEventListener("click", () => setTab(button.dataset.demoTab)); -}); - -if (window.location.hash) { - window.setTimeout(() => { - document.querySelector(window.location.hash)?.scrollIntoView(); - }, 80); -} - -document.querySelectorAll("[data-quick-action]").forEach((button) => { - button.addEventListener("click", () => { - const action = button.dataset.quickAction; - if (action === "complete") { - completeSet(); - } - if (action === "risk") { - demoState.readiness = 61; - demoState.streak = Math.max(1, demoState.streak); - setTab("recovery"); - els.metricTitle.textContent = "Streak risk"; - els.metricCopy.textContent = "A short proof session keeps the daily chain alive."; - setFox("watch"); - } - if (action === "level") { - demoState.level += 1; - demoState.xp = 12; - demoState.grade = demoState.level >= 10 ? "Steel" : "Iron"; - setTab("ledger"); - setFox("proud"); - } - }); -}); - -els.themeButtons.forEach((button) => { - button.addEventListener("click", () => { - demoState.theme = button.dataset.themeChoice; - document.documentElement.dataset.siteTheme = demoState.theme; - els.themeButtons.forEach((themeButton) => { - themeButton.classList.toggle("is-active", themeButton === button); - }); - }); -}); - -try { - renderDemo(); - setupRevealObserver(); -} catch (error) { - revealVisibleSections(); - console.error("IronLog demo failed to initialize", error); -} diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..2a13059 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,149 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.kapt") + id("org.jetbrains.kotlin.plugin.serialization") + id("org.jetbrains.kotlin.plugin.compose") // Kotlin 2.x Compose compiler plugin +} +apply(plugin = "io.objectbox") + +// Pin Kotlin/serialization artifacts to 2.1.0 (Kotlin 2.x baseline). +configurations.all { + resolutionStrategy.force( + "org.jetbrains.kotlin:kotlin-stdlib:2.1.0", + "org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.0", + "org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.0", + "org.jetbrains.kotlin:kotlin-stdlib-common:2.1.0", + "org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3", + "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3" + ) +} + +val localProps = Properties().apply { + val f = rootProject.file("local.properties") + if (f.exists()) load(f.inputStream()) +} + +android { + namespace = "com.ironlog.app" + compileSdk = 36 + + defaultConfig { + applicationId = "com.ironlogpro.app" + minSdk = 26 + targetSdk = 36 + versionCode = localProps.getProperty("version.code", "1").toInt() + versionName = localProps.getProperty("version.name", "1.0.0") + } + + buildFeatures { compose = true; buildConfig = true } + // composeOptions.kotlinCompilerExtensionVersion is no longer needed with the + // org.jetbrains.kotlin.plugin.compose plugin (Kotlin 2.x). The compiler is + // bundled with the plugin and automatically matches the Kotlin version. + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + signingConfigs { + create("release") { + storeFile = file("ironlog-release.jks") + storePassword = localProps.getProperty("signing.storePassword", "") + keyAlias = "Ironlog" + keyPassword = localProps.getProperty("signing.keyPassword", "") + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + signingConfig = signingConfigs.getByName("release") + } + } + +} + +dependencies { + implementation("androidx.core:core-ktx:1.16.0") + implementation("androidx.activity:activity-compose:1.10.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.1") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1") + implementation("androidx.navigation:navigation-compose:2.8.9") + implementation(platform("androidx.compose:compose-bom:2025.06.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material:material-icons-extended") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("com.google.code.gson:gson:2.11.0") + // Ktor Client — coroutine-native HTTP, replaces raw OkHttp in CloudAiEngine + val ktorVersion = "3.1.3" + implementation("io.ktor:ktor-client-android:$ktorVersion") + implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") + implementation("io.objectbox:objectbox-kotlin:5.4.2") + kapt("io.objectbox:objectbox-processor:5.4.2") + implementation("androidx.work:work-runtime-ktx:2.9.1") + + // Haze — hardware blur for tab bar frosted glass + implementation("dev.chrisbanes.haze:haze:1.6.7") + + // V1 premium UI + implementation("com.airbnb.android:lottie-compose:6.7.1") + implementation("sh.calvin.reorderable:reorderable:3.1.0") + implementation("com.valentinilk.shimmer:compose-shimmer:1.3.1") + implementation("io.github.vinceglb:confettikit-android:0.4.0") + + // Gemini Nano on-device inference via Android AICore + implementation("com.google.ai.edge.aicore:aicore:0.0.1-exp02") + + // API key encryption — EncryptedSharedPreferences backed by Android Keystore + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + // Health Connect (replaces deprecated Google Fit) + implementation("androidx.health.connect:connect-client:1.1.0-alpha07") + + // QR code generation (pure-Kotlin, no native dependencies) + implementation("io.github.g0dkar:qrcode-kotlin-android:4.2.0") + + // ML Kit Barcode Scanning — camera QR scan + implementation("com.google.mlkit:barcode-scanning:17.3.0") + + // CameraX — required by ML Kit scanner + implementation("androidx.camera:camera-camera2:1.4.2") + implementation("androidx.camera:camera-lifecycle:1.4.2") + implementation("androidx.camera:camera-view:1.4.2") + + // Jetpack Glance — Compose-based app widgets + implementation("androidx.glance:glance-appwidget:1.1.1") + implementation("androidx.glance:glance-material3:1.1.1") + + // Vico — Compose chart library (replaces hand-drawn Canvas charts) + implementation("com.patrykandpatrick.vico:compose-m3:2.1.2") + + // Accompanist Permissions — Compose-native permission state (replaces ActivityResultContracts boilerplate) + implementation("com.google.accompanist:accompanist-permissions:0.37.2") + + // Coil 3 — async image loading, memory-safe (replaces BitmapFactory boilerplate) + implementation("io.coil-kt.coil3:coil-compose:3.2.0") + implementation("io.coil-kt.coil3:coil-android:3.2.0") // required for content:// Uri decoding on Android + + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2") + implementation("com.jakewharton.timber:timber:5.0.1") + + testImplementation("junit:junit:4.13.2") + + // Later / optional — uncomment when needed + // implementation("app.rive:rive-android:9.7.2") +} diff --git a/app/objectbox-models/default.json b/app/objectbox-models/default.json new file mode 100644 index 0000000..71bda27 --- /dev/null +++ b/app/objectbox-models/default.json @@ -0,0 +1,1176 @@ +{ + "_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.", + "_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.", + "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", + "entities": [ + { + "id": "1:6375078547597424337", + "lastPropertyId": "5:17079493708331395", + "name": "AppSettingEntity", + "properties": [ + { + "id": "1:7742540520026219512", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:1506185647286150166", + "name": "key", + "indexId": "1:5049298815426413490", + "type": 9, + "flags": 34848 + }, + { + "id": "3:4365713378798394968", + "name": "value", + "type": 9 + }, + { + "id": "4:3466618677031964009", + "name": "valueType", + "type": 9 + }, + { + "id": "5:17079493708331395", + "name": "updatedAt", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "2:302515929721921433", + "lastPropertyId": "11:1212075822830558583", + "name": "BodyMeasurementEntity", + "properties": [ + { + "id": "1:1130576062898537770", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:7002735551031013151", + "name": "uid", + "indexId": "2:2161480310796959098", + "type": 9, + "flags": 2080 + }, + { + "id": "3:1202162678026618492", + "name": "measuredAt", + "indexId": "3:7005559575458638302", + "type": 6, + "flags": 8 + }, + { + "id": "4:3369436351573316369", + "name": "bodyweight", + "type": 8 + }, + { + "id": "5:4047965594104437000", + "name": "waist", + "type": 8 + }, + { + "id": "6:2396368223031782704", + "name": "chest", + "type": 8 + }, + { + "id": "7:2401092920189964658", + "name": "arm", + "type": 8 + }, + { + "id": "8:4001967161472508852", + "name": "thigh", + "type": 8 + }, + { + "id": "9:4887603775309062493", + "name": "notes", + "type": 9 + }, + { + "id": "10:90164861556050784", + "name": "createdAt", + "type": 6 + }, + { + "id": "11:1212075822830558583", + "name": "updatedAt", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "3:2776827714978953582", + "lastPropertyId": "3:7294532530793950561", + "name": "DbSmokeTestEntity", + "properties": [ + { + "id": "1:3998339153698662456", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:2284681944254414824", + "name": "label", + "type": 9 + }, + { + "id": "3:7294532530793950561", + "name": "createdAt", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "4:2611743154182790160", + "lastPropertyId": "22:7015189296577759845", + "name": "ExerciseEntity", + "properties": [ + { + "id": "1:707020838361347369", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:8686808791081191654", + "name": "uid", + "indexId": "4:7132074033215478825", + "type": 9, + "flags": 2080 + }, + { + "id": "3:6378664439129185066", + "name": "name", + "indexId": "5:8365493407371463395", + "type": 9, + "flags": 2048 + }, + { + "id": "4:4725244426715213154", + "name": "normalizedName", + "indexId": "6:6776885220009030455", + "type": 9, + "flags": 2048 + }, + { + "id": "5:4951136480696017650", + "name": "primaryMuscle", + "indexId": "7:1970750443334186444", + "type": 9, + "flags": 2048 + }, + { + "id": "6:2998988745134536935", + "name": "equipment", + "indexId": "8:2948446275981894937", + "type": 9, + "flags": 2048 + }, + { + "id": "7:3430537712107722538", + "name": "category", + "type": 9 + }, + { + "id": "8:5251491483762025944", + "name": "isCustom", + "indexId": "9:461564175794847502", + "type": 1, + "flags": 8 + }, + { + "id": "9:4734001117345052729", + "name": "source", + "type": 9 + }, + { + "id": "10:7611015891308048663", + "name": "notes", + "type": 9 + }, + { + "id": "11:6149860496354979775", + "name": "createdAt", + "type": 6 + }, + { + "id": "12:3907671605398701249", + "name": "updatedAt", + "type": 6 + }, + { + "id": "13:2465688578270422651", + "name": "equipmentDetail", + "type": 9 + }, + { + "id": "14:3502121968086585412", + "name": "apparatusJson", + "type": 9 + }, + { + "id": "15:8325048520281849311", + "name": "trackingType", + "type": 9 + }, + { + "id": "16:3603078725114757033", + "name": "isBodyweight", + "type": 1 + }, + { + "id": "17:6352733492945205005", + "name": "requiresExternalLoad", + "type": 1 + }, + { + "id": "18:6057663680977537429", + "name": "movementPattern", + "type": 9 + }, + { + "id": "19:85311505857371948", + "name": "difficulty", + "type": 9 + }, + { + "id": "20:359137109709133643", + "name": "aliasesJson", + "type": 9 + }, + { + "id": "21:6440091724420935180", + "name": "sourceTagsJson", + "type": 9 + }, + { + "id": "22:7015189296577759845", + "name": "secondaryMusclesJson", + "type": 9 + } + ], + "relations": [] + }, + { + "id": "5:8631095935501798316", + "lastPropertyId": "9:6465717322369737282", + "name": "ExerciseMuscleEntity", + "properties": [ + { + "id": "1:8274426856253767721", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:2048096962213666555", + "name": "uid", + "indexId": "10:6990987628250713075", + "type": 9, + "flags": 34848 + }, + { + "id": "3:101629237908960009", + "name": "exerciseUid", + "indexId": "11:3782810450716833369", + "type": 9, + "flags": 2048 + }, + { + "id": "4:7502942524137374737", + "name": "muscle", + "indexId": "12:7012750980129844156", + "type": 9, + "flags": 2048 + }, + { + "id": "5:8168274513786920235", + "name": "role", + "type": 9 + }, + { + "id": "6:419721243428278799", + "name": "contributionFraction", + "type": 8 + }, + { + "id": "7:106423071448750581", + "name": "createdAt", + "type": 6 + }, + { + "id": "8:4138816793755933269", + "name": "updatedAt", + "type": 6 + }, + { + "id": "9:6465717322369737282", + "name": "exerciseId", + "indexId": "13:1318094819683171599", + "type": 11, + "flags": 520, + "relationTarget": "ExerciseEntity" + } + ], + "relations": [] + }, + { + "id": "6:6570651244400711970", + "lastPropertyId": "9:7726916900838342434", + "name": "PlanDayEntity", + "properties": [ + { + "id": "1:8428790323057535583", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:6713781316084591169", + "name": "uid", + "indexId": "14:4708930405433038414", + "type": 9, + "flags": 2080 + }, + { + "id": "3:5596649429300276786", + "name": "planUid", + "indexId": "15:1170374646201020618", + "type": 9, + "flags": 2048 + }, + { + "id": "4:4618565955832613535", + "name": "name", + "type": 9 + }, + { + "id": "5:2084917564195841564", + "name": "color", + "type": 9 + }, + { + "id": "6:8217789439750490373", + "name": "orderIndex", + "indexId": "16:2934401188564034565", + "type": 5, + "flags": 8 + }, + { + "id": "7:598708148343689483", + "name": "createdAt", + "type": 6 + }, + { + "id": "8:2705189211186920977", + "name": "updatedAt", + "type": 6 + }, + { + "id": "9:7726916900838342434", + "name": "planId", + "indexId": "17:1172575833296806094", + "type": 11, + "flags": 520, + "relationTarget": "PlanEntity" + } + ], + "relations": [] + }, + { + "id": "7:2309357112192973168", + "lastPropertyId": "8:3400899846978005693", + "name": "PlanEntity", + "properties": [ + { + "id": "1:5230162920711976621", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:230772893546029768", + "name": "uid", + "indexId": "18:6786941208543501239", + "type": 9, + "flags": 2080 + }, + { + "id": "3:959653943456243202", + "name": "name", + "type": 9 + }, + { + "id": "4:3571178027715349395", + "name": "goal", + "type": 9 + }, + { + "id": "5:1536087741652199157", + "name": "description", + "type": 9 + }, + { + "id": "6:513307857960897590", + "name": "isActive", + "indexId": "19:3989058999613216512", + "type": 1, + "flags": 8 + }, + { + "id": "7:5040926330099815887", + "name": "createdAt", + "type": 6 + }, + { + "id": "8:3400899846978005693", + "name": "updatedAt", + "indexId": "20:4423634925057715172", + "type": 6, + "flags": 8 + } + ], + "relations": [] + }, + { + "id": "8:5642091058245945037", + "lastPropertyId": "15:7982953182248628598", + "name": "PlanExerciseEntity", + "properties": [ + { + "id": "1:5071294513351235515", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:3202045242703233318", + "name": "uid", + "indexId": "21:304590405260949575", + "type": 9, + "flags": 2080 + }, + { + "id": "3:8222477767718912412", + "name": "planDayUid", + "indexId": "22:1845388010696193065", + "type": 9, + "flags": 2048 + }, + { + "id": "4:1002880908358576484", + "name": "exerciseUid", + "indexId": "23:4431756976114034537", + "type": 9, + "flags": 2048 + }, + { + "id": "5:5357654532262209906", + "name": "orderIndex", + "indexId": "24:7624470436801051403", + "type": 5, + "flags": 8 + }, + { + "id": "6:6984689457390584612", + "name": "sets", + "type": 5 + }, + { + "id": "7:6365967288573418627", + "name": "reps", + "type": 9 + }, + { + "id": "8:305845508335874805", + "name": "restSeconds", + "type": 5 + }, + { + "id": "9:6146596943296265001", + "name": "supersetGroup", + "type": 9 + }, + { + "id": "10:1318994208334733938", + "name": "isWarmup", + "type": 1 + }, + { + "id": "11:4663791005614629538", + "name": "notes", + "type": 9 + }, + { + "id": "12:2039237830902713370", + "name": "createdAt", + "type": 6 + }, + { + "id": "13:4454896248020535574", + "name": "updatedAt", + "type": 6 + }, + { + "id": "14:760941413455639622", + "name": "planDayId", + "indexId": "25:87929194468686219", + "type": 11, + "flags": 520, + "relationTarget": "PlanDayEntity" + }, + { + "id": "15:7982953182248628598", + "name": "exerciseId", + "indexId": "26:6009269294358758361", + "type": 11, + "flags": 520, + "relationTarget": "ExerciseEntity" + } + ], + "relations": [] + }, + { + "id": "9:4539425119869419764", + "lastPropertyId": "8:292678578060526509", + "name": "ProgressPhotoEntity", + "properties": [ + { + "id": "1:5494904852557629796", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:175972985349033470", + "name": "uid", + "indexId": "27:687419790969163709", + "type": 9, + "flags": 2080 + }, + { + "id": "3:3717031907422351170", + "name": "fileUri", + "type": 9 + }, + { + "id": "4:5688752858919753694", + "name": "takenAt", + "indexId": "28:1626483308514503498", + "type": 6, + "flags": 8 + }, + { + "id": "5:2548186096965648050", + "name": "bodyweight", + "type": 8 + }, + { + "id": "6:9146382119571494830", + "name": "notes", + "type": 9 + }, + { + "id": "7:4210445366891782807", + "name": "createdAt", + "type": 6 + }, + { + "id": "8:292678578060526509", + "name": "updatedAt", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "10:8896672983816278611", + "lastPropertyId": "16:1999541678396910812", + "name": "WorkoutEntity", + "properties": [ + { + "id": "1:9170482815713570413", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:8953567437095390656", + "name": "uid", + "indexId": "29:1304477636819706961", + "type": 9, + "flags": 2080 + }, + { + "id": "3:730259752772027732", + "name": "planUid", + "indexId": "30:5331943758443322541", + "type": 9, + "flags": 2048 + }, + { + "id": "4:7548509774931883365", + "name": "planDayUid", + "indexId": "31:970724848359515934", + "type": 9, + "flags": 2048 + }, + { + "id": "5:6826067441618715582", + "name": "name", + "type": 9 + }, + { + "id": "6:2882884329284383374", + "name": "startedAt", + "indexId": "32:3623862218936235916", + "type": 6, + "flags": 8 + }, + { + "id": "7:2975769613296758001", + "name": "completedAt", + "type": 6 + }, + { + "id": "8:2781354287316497412", + "name": "durationSeconds", + "type": 5 + }, + { + "id": "9:987891453695385349", + "name": "rating", + "type": 8 + }, + { + "id": "10:4394575519908125655", + "name": "notes", + "type": 9 + }, + { + "id": "11:3528795408108132268", + "name": "status", + "indexId": "33:99442323983545284", + "type": 9, + "flags": 2048 + }, + { + "id": "12:9031113332705334533", + "name": "createdAt", + "type": 6 + }, + { + "id": "13:6370380062255031895", + "name": "updatedAt", + "type": 6 + }, + { + "id": "14:1794134261713451605", + "name": "planId", + "indexId": "34:7150481635002111418", + "type": 11, + "flags": 520, + "relationTarget": "PlanEntity" + }, + { + "id": "15:2664564091801544337", + "name": "planDayId", + "indexId": "35:6005115109596506198", + "type": 11, + "flags": 520, + "relationTarget": "PlanDayEntity" + }, + { + "id": "16:1999541678396910812", + "name": "imported", + "type": 1 + } + ], + "relations": [] + }, + { + "id": "11:4741913948774127407", + "lastPropertyId": "11:621276958343896092", + "name": "WorkoutExerciseEntity", + "properties": [ + { + "id": "1:7464516990309689993", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:3491438182960818812", + "name": "uid", + "indexId": "36:421058826231474802", + "type": 9, + "flags": 2080 + }, + { + "id": "3:6887240933183881732", + "name": "workoutUid", + "indexId": "37:3363423963746750088", + "type": 9, + "flags": 2048 + }, + { + "id": "4:7587133369804765978", + "name": "exerciseUid", + "indexId": "38:7985944065281696022", + "type": 9, + "flags": 2048 + }, + { + "id": "5:8183794936877277097", + "name": "orderIndex", + "indexId": "39:7269429662349370187", + "type": 5, + "flags": 8 + }, + { + "id": "6:2624899628615480306", + "name": "supersetGroup", + "type": 9 + }, + { + "id": "7:7306694230480957983", + "name": "notes", + "type": 9 + }, + { + "id": "8:3203356354807555103", + "name": "createdAt", + "type": 6 + }, + { + "id": "9:5230424591224576166", + "name": "updatedAt", + "type": 6 + }, + { + "id": "10:2116151153414940385", + "name": "workoutId", + "indexId": "40:3944651843168485357", + "type": 11, + "flags": 520, + "relationTarget": "WorkoutEntity" + }, + { + "id": "11:621276958343896092", + "name": "exerciseId", + "indexId": "41:6525490956396544282", + "type": 11, + "flags": 520, + "relationTarget": "ExerciseEntity" + } + ], + "relations": [] + }, + { + "id": "12:4360833741220823326", + "lastPropertyId": "17:5498549554904472762", + "name": "WorkoutSetEntity", + "properties": [ + { + "id": "1:4626736428916203101", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:5346374336183447602", + "name": "uid", + "indexId": "42:2131922783263931925", + "type": 9, + "flags": 2080 + }, + { + "id": "3:5618714557746405252", + "name": "workoutExerciseUid", + "indexId": "43:1920467783060483230", + "type": 9, + "flags": 2048 + }, + { + "id": "4:7019894755280663324", + "name": "setIndex", + "indexId": "44:1947623668304261528", + "type": 5, + "flags": 8 + }, + { + "id": "5:8578473842864728696", + "name": "weight", + "type": 8 + }, + { + "id": "6:8210998745392447185", + "name": "reps", + "type": 8 + }, + { + "id": "7:1383989929661473426", + "name": "rpe", + "type": 8 + }, + { + "id": "8:8319954115125233190", + "name": "rir", + "type": 8 + }, + { + "id": "9:2478622815753987363", + "name": "restSeconds", + "type": 5 + }, + { + "id": "10:2393261358309395291", + "name": "isWarmup", + "indexId": "45:2655474054443381086", + "type": 1, + "flags": 8 + }, + { + "id": "11:2789832380678253136", + "name": "isDropset", + "type": 1 + }, + { + "id": "12:600594436099191511", + "name": "isAmrap", + "type": 1 + }, + { + "id": "13:562173916802785475", + "name": "toFailure", + "type": 1 + }, + { + "id": "14:7771050552602058712", + "name": "completedAt", + "type": 6 + }, + { + "id": "15:5045205436875128579", + "name": "createdAt", + "type": 6 + }, + { + "id": "16:3655167121615541243", + "name": "updatedAt", + "type": 6 + }, + { + "id": "17:5498549554904472762", + "name": "workoutExerciseId", + "indexId": "46:1969267965260840604", + "type": 11, + "flags": 520, + "relationTarget": "WorkoutExerciseEntity" + } + ], + "relations": [] + }, + { + "id": "13:872393084237455310", + "lastPropertyId": "13:8058996708874307559", + "name": "GamificationProfileEntity", + "properties": [ + { + "id": "1:4420681973009124567", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:5581856577428156502", + "name": "offlineUserId", + "indexId": "47:7996134452892434897", + "type": 9, + "flags": 2080 + }, + { + "id": "3:3439692452007655530", + "name": "totalXp", + "type": 6 + }, + { + "id": "4:8269480094590442427", + "name": "level", + "type": 5 + }, + { + "id": "5:6560246734718259827", + "name": "xpInLevel", + "type": 6 + }, + { + "id": "6:3346618059051021487", + "name": "rank", + "type": 9 + }, + { + "id": "7:963411976398449338", + "name": "activeTitle", + "type": 9 + }, + { + "id": "8:8288198492989634710", + "name": "statsJson", + "type": 9 + }, + { + "id": "9:63936376127024961", + "name": "weeklyXp", + "type": 5 + }, + { + "id": "10:8460930991603249917", + "name": "weeklyXpResetWeek", + "type": 9 + }, + { + "id": "11:7666440753031599367", + "name": "streakWeeks", + "type": 5 + }, + { + "id": "12:6171387056460869585", + "name": "makeupCompletionsJson", + "type": 9 + }, + { + "id": "13:8058996708874307559", + "name": "unlockedBadges", + "type": 9 + } + ], + "relations": [] + }, + { + "id": "15:3303933602031657554", + "lastPropertyId": "19:6205034933046485444", + "name": "AthleteCalibrationEntity", + "properties": [ + { + "id": "1:3827532953057363661", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:5078454025712408421", + "name": "offlineUserId", + "indexId": "48:2797616992507402666", + "type": 9, + "flags": 2080 + }, + { + "id": "3:7536905653511905638", + "name": "trainingAgeMonths", + "type": 5 + }, + { + "id": "4:770717731337440208", + "name": "firstVerifiedSessionAt", + "type": 6 + }, + { + "id": "5:8006972148803772392", + "name": "weightUnit", + "type": 9 + }, + { + "id": "6:2990630982597146260", + "name": "bodyweightKg", + "type": 8 + }, + { + "id": "7:936944022977183748", + "name": "goalMode", + "type": 9 + }, + { + "id": "8:63072361060061501", + "name": "weeklyGoalDays", + "type": 5 + }, + { + "id": "9:1287460578734026252", + "name": "importedHistory", + "type": 1 + }, + { + "id": "10:5524069434125522240", + "name": "confidence", + "type": 8 + }, + { + "id": "11:6581969964691433784", + "name": "updatedAt", + "type": 6 + }, + { + "id": "12:4741176203616204863", + "name": "hasPastTraining", + "type": 1 + }, + { + "id": "13:5813023527605646507", + "name": "hasGymAccess", + "type": 1 + }, + { + "id": "14:1159119518610722754", + "name": "baselinePushups", + "type": 5 + }, + { + "id": "15:7164010130302322246", + "name": "baselinePullups", + "type": 5 + }, + { + "id": "16:4733081696383155826", + "name": "baselineBenchKg", + "type": 5 + }, + { + "id": "17:2469407931951061224", + "name": "baselineLatPulldownKg", + "type": 5 + }, + { + "id": "18:5488276568647307231", + "name": "baselineMileRunSeconds", + "type": 5 + }, + { + "id": "19:6205034933046485444", + "name": "historicalTrainingDaysPerWeek", + "type": 5 + } + ], + "relations": [] + }, + { + "id": "16:5759065875517646429", + "lastPropertyId": "12:4155430192547483927", + "name": "IronLedgerEventEntity", + "properties": [ + { + "id": "1:211267648957640289", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:7891121349817722156", + "name": "eventId", + "indexId": "49:3320730592556355093", + "type": 9, + "flags": 2080 + }, + { + "id": "3:422406808856606259", + "name": "sourceType", + "indexId": "50:1912751806984890698", + "type": 9, + "flags": 2048 + }, + { + "id": "4:8409752256982048584", + "name": "sourceId", + "indexId": "51:9104188331982479815", + "type": 9, + "flags": 2048 + }, + { + "id": "5:4871368561614387169", + "name": "eventKind", + "indexId": "52:4130863557055800333", + "type": 9, + "flags": 2048 + }, + { + "id": "6:1782006764642706504", + "name": "occurredAt", + "indexId": "53:3101713585028288796", + "type": 6, + "flags": 8 + }, + { + "id": "7:3581981213273908444", + "name": "xpDelta", + "type": 5 + }, + { + "id": "8:4343578316348105", + "name": "statDeltasJson", + "type": 9 + }, + { + "id": "9:3755353096104464687", + "name": "trustScore", + "type": 8 + }, + { + "id": "10:4028109913350867797", + "name": "fingerprint", + "indexId": "54:996889235122584713", + "type": 9, + "flags": 2048 + }, + { + "id": "11:759123915799413798", + "name": "metadataJson", + "type": 9 + }, + { + "id": "12:4155430192547483927", + "name": "invalidated", + "indexId": "55:7679737323583470378", + "type": 1, + "flags": 8 + } + ], + "relations": [] + } + ], + "lastEntityId": "16:5759065875517646429", + "lastIndexId": "55:7679737323583470378", + "lastRelationId": "0:0", + "lastSequenceId": "0:0", + "modelVersion": 5, + "modelVersionParserMinimum": 5, + "retiredEntityUids": [ + 885356565461564884 + ], + "retiredIndexUids": [], + "retiredPropertyUids": [ + 8743783797112242118, + 6362880859759003247, + 6216156919604802650, + 1447604901136741389, + 6825076196334837396, + 1279230704031176310, + 7102108765480323005, + 4485041613321335939, + 1192629098759155910, + 7199381384699893764, + 353580902206095814 + ], + "retiredRelationUids": [], + "version": 1 +} \ No newline at end of file diff --git a/app/objectbox-models/default.json.bak b/app/objectbox-models/default.json.bak new file mode 100644 index 0000000..71bda27 --- /dev/null +++ b/app/objectbox-models/default.json.bak @@ -0,0 +1,1176 @@ +{ + "_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.", + "_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.", + "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", + "entities": [ + { + "id": "1:6375078547597424337", + "lastPropertyId": "5:17079493708331395", + "name": "AppSettingEntity", + "properties": [ + { + "id": "1:7742540520026219512", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:1506185647286150166", + "name": "key", + "indexId": "1:5049298815426413490", + "type": 9, + "flags": 34848 + }, + { + "id": "3:4365713378798394968", + "name": "value", + "type": 9 + }, + { + "id": "4:3466618677031964009", + "name": "valueType", + "type": 9 + }, + { + "id": "5:17079493708331395", + "name": "updatedAt", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "2:302515929721921433", + "lastPropertyId": "11:1212075822830558583", + "name": "BodyMeasurementEntity", + "properties": [ + { + "id": "1:1130576062898537770", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:7002735551031013151", + "name": "uid", + "indexId": "2:2161480310796959098", + "type": 9, + "flags": 2080 + }, + { + "id": "3:1202162678026618492", + "name": "measuredAt", + "indexId": "3:7005559575458638302", + "type": 6, + "flags": 8 + }, + { + "id": "4:3369436351573316369", + "name": "bodyweight", + "type": 8 + }, + { + "id": "5:4047965594104437000", + "name": "waist", + "type": 8 + }, + { + "id": "6:2396368223031782704", + "name": "chest", + "type": 8 + }, + { + "id": "7:2401092920189964658", + "name": "arm", + "type": 8 + }, + { + "id": "8:4001967161472508852", + "name": "thigh", + "type": 8 + }, + { + "id": "9:4887603775309062493", + "name": "notes", + "type": 9 + }, + { + "id": "10:90164861556050784", + "name": "createdAt", + "type": 6 + }, + { + "id": "11:1212075822830558583", + "name": "updatedAt", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "3:2776827714978953582", + "lastPropertyId": "3:7294532530793950561", + "name": "DbSmokeTestEntity", + "properties": [ + { + "id": "1:3998339153698662456", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:2284681944254414824", + "name": "label", + "type": 9 + }, + { + "id": "3:7294532530793950561", + "name": "createdAt", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "4:2611743154182790160", + "lastPropertyId": "22:7015189296577759845", + "name": "ExerciseEntity", + "properties": [ + { + "id": "1:707020838361347369", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:8686808791081191654", + "name": "uid", + "indexId": "4:7132074033215478825", + "type": 9, + "flags": 2080 + }, + { + "id": "3:6378664439129185066", + "name": "name", + "indexId": "5:8365493407371463395", + "type": 9, + "flags": 2048 + }, + { + "id": "4:4725244426715213154", + "name": "normalizedName", + "indexId": "6:6776885220009030455", + "type": 9, + "flags": 2048 + }, + { + "id": "5:4951136480696017650", + "name": "primaryMuscle", + "indexId": "7:1970750443334186444", + "type": 9, + "flags": 2048 + }, + { + "id": "6:2998988745134536935", + "name": "equipment", + "indexId": "8:2948446275981894937", + "type": 9, + "flags": 2048 + }, + { + "id": "7:3430537712107722538", + "name": "category", + "type": 9 + }, + { + "id": "8:5251491483762025944", + "name": "isCustom", + "indexId": "9:461564175794847502", + "type": 1, + "flags": 8 + }, + { + "id": "9:4734001117345052729", + "name": "source", + "type": 9 + }, + { + "id": "10:7611015891308048663", + "name": "notes", + "type": 9 + }, + { + "id": "11:6149860496354979775", + "name": "createdAt", + "type": 6 + }, + { + "id": "12:3907671605398701249", + "name": "updatedAt", + "type": 6 + }, + { + "id": "13:2465688578270422651", + "name": "equipmentDetail", + "type": 9 + }, + { + "id": "14:3502121968086585412", + "name": "apparatusJson", + "type": 9 + }, + { + "id": "15:8325048520281849311", + "name": "trackingType", + "type": 9 + }, + { + "id": "16:3603078725114757033", + "name": "isBodyweight", + "type": 1 + }, + { + "id": "17:6352733492945205005", + "name": "requiresExternalLoad", + "type": 1 + }, + { + "id": "18:6057663680977537429", + "name": "movementPattern", + "type": 9 + }, + { + "id": "19:85311505857371948", + "name": "difficulty", + "type": 9 + }, + { + "id": "20:359137109709133643", + "name": "aliasesJson", + "type": 9 + }, + { + "id": "21:6440091724420935180", + "name": "sourceTagsJson", + "type": 9 + }, + { + "id": "22:7015189296577759845", + "name": "secondaryMusclesJson", + "type": 9 + } + ], + "relations": [] + }, + { + "id": "5:8631095935501798316", + "lastPropertyId": "9:6465717322369737282", + "name": "ExerciseMuscleEntity", + "properties": [ + { + "id": "1:8274426856253767721", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:2048096962213666555", + "name": "uid", + "indexId": "10:6990987628250713075", + "type": 9, + "flags": 34848 + }, + { + "id": "3:101629237908960009", + "name": "exerciseUid", + "indexId": "11:3782810450716833369", + "type": 9, + "flags": 2048 + }, + { + "id": "4:7502942524137374737", + "name": "muscle", + "indexId": "12:7012750980129844156", + "type": 9, + "flags": 2048 + }, + { + "id": "5:8168274513786920235", + "name": "role", + "type": 9 + }, + { + "id": "6:419721243428278799", + "name": "contributionFraction", + "type": 8 + }, + { + "id": "7:106423071448750581", + "name": "createdAt", + "type": 6 + }, + { + "id": "8:4138816793755933269", + "name": "updatedAt", + "type": 6 + }, + { + "id": "9:6465717322369737282", + "name": "exerciseId", + "indexId": "13:1318094819683171599", + "type": 11, + "flags": 520, + "relationTarget": "ExerciseEntity" + } + ], + "relations": [] + }, + { + "id": "6:6570651244400711970", + "lastPropertyId": "9:7726916900838342434", + "name": "PlanDayEntity", + "properties": [ + { + "id": "1:8428790323057535583", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:6713781316084591169", + "name": "uid", + "indexId": "14:4708930405433038414", + "type": 9, + "flags": 2080 + }, + { + "id": "3:5596649429300276786", + "name": "planUid", + "indexId": "15:1170374646201020618", + "type": 9, + "flags": 2048 + }, + { + "id": "4:4618565955832613535", + "name": "name", + "type": 9 + }, + { + "id": "5:2084917564195841564", + "name": "color", + "type": 9 + }, + { + "id": "6:8217789439750490373", + "name": "orderIndex", + "indexId": "16:2934401188564034565", + "type": 5, + "flags": 8 + }, + { + "id": "7:598708148343689483", + "name": "createdAt", + "type": 6 + }, + { + "id": "8:2705189211186920977", + "name": "updatedAt", + "type": 6 + }, + { + "id": "9:7726916900838342434", + "name": "planId", + "indexId": "17:1172575833296806094", + "type": 11, + "flags": 520, + "relationTarget": "PlanEntity" + } + ], + "relations": [] + }, + { + "id": "7:2309357112192973168", + "lastPropertyId": "8:3400899846978005693", + "name": "PlanEntity", + "properties": [ + { + "id": "1:5230162920711976621", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:230772893546029768", + "name": "uid", + "indexId": "18:6786941208543501239", + "type": 9, + "flags": 2080 + }, + { + "id": "3:959653943456243202", + "name": "name", + "type": 9 + }, + { + "id": "4:3571178027715349395", + "name": "goal", + "type": 9 + }, + { + "id": "5:1536087741652199157", + "name": "description", + "type": 9 + }, + { + "id": "6:513307857960897590", + "name": "isActive", + "indexId": "19:3989058999613216512", + "type": 1, + "flags": 8 + }, + { + "id": "7:5040926330099815887", + "name": "createdAt", + "type": 6 + }, + { + "id": "8:3400899846978005693", + "name": "updatedAt", + "indexId": "20:4423634925057715172", + "type": 6, + "flags": 8 + } + ], + "relations": [] + }, + { + "id": "8:5642091058245945037", + "lastPropertyId": "15:7982953182248628598", + "name": "PlanExerciseEntity", + "properties": [ + { + "id": "1:5071294513351235515", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:3202045242703233318", + "name": "uid", + "indexId": "21:304590405260949575", + "type": 9, + "flags": 2080 + }, + { + "id": "3:8222477767718912412", + "name": "planDayUid", + "indexId": "22:1845388010696193065", + "type": 9, + "flags": 2048 + }, + { + "id": "4:1002880908358576484", + "name": "exerciseUid", + "indexId": "23:4431756976114034537", + "type": 9, + "flags": 2048 + }, + { + "id": "5:5357654532262209906", + "name": "orderIndex", + "indexId": "24:7624470436801051403", + "type": 5, + "flags": 8 + }, + { + "id": "6:6984689457390584612", + "name": "sets", + "type": 5 + }, + { + "id": "7:6365967288573418627", + "name": "reps", + "type": 9 + }, + { + "id": "8:305845508335874805", + "name": "restSeconds", + "type": 5 + }, + { + "id": "9:6146596943296265001", + "name": "supersetGroup", + "type": 9 + }, + { + "id": "10:1318994208334733938", + "name": "isWarmup", + "type": 1 + }, + { + "id": "11:4663791005614629538", + "name": "notes", + "type": 9 + }, + { + "id": "12:2039237830902713370", + "name": "createdAt", + "type": 6 + }, + { + "id": "13:4454896248020535574", + "name": "updatedAt", + "type": 6 + }, + { + "id": "14:760941413455639622", + "name": "planDayId", + "indexId": "25:87929194468686219", + "type": 11, + "flags": 520, + "relationTarget": "PlanDayEntity" + }, + { + "id": "15:7982953182248628598", + "name": "exerciseId", + "indexId": "26:6009269294358758361", + "type": 11, + "flags": 520, + "relationTarget": "ExerciseEntity" + } + ], + "relations": [] + }, + { + "id": "9:4539425119869419764", + "lastPropertyId": "8:292678578060526509", + "name": "ProgressPhotoEntity", + "properties": [ + { + "id": "1:5494904852557629796", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:175972985349033470", + "name": "uid", + "indexId": "27:687419790969163709", + "type": 9, + "flags": 2080 + }, + { + "id": "3:3717031907422351170", + "name": "fileUri", + "type": 9 + }, + { + "id": "4:5688752858919753694", + "name": "takenAt", + "indexId": "28:1626483308514503498", + "type": 6, + "flags": 8 + }, + { + "id": "5:2548186096965648050", + "name": "bodyweight", + "type": 8 + }, + { + "id": "6:9146382119571494830", + "name": "notes", + "type": 9 + }, + { + "id": "7:4210445366891782807", + "name": "createdAt", + "type": 6 + }, + { + "id": "8:292678578060526509", + "name": "updatedAt", + "type": 6 + } + ], + "relations": [] + }, + { + "id": "10:8896672983816278611", + "lastPropertyId": "16:1999541678396910812", + "name": "WorkoutEntity", + "properties": [ + { + "id": "1:9170482815713570413", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:8953567437095390656", + "name": "uid", + "indexId": "29:1304477636819706961", + "type": 9, + "flags": 2080 + }, + { + "id": "3:730259752772027732", + "name": "planUid", + "indexId": "30:5331943758443322541", + "type": 9, + "flags": 2048 + }, + { + "id": "4:7548509774931883365", + "name": "planDayUid", + "indexId": "31:970724848359515934", + "type": 9, + "flags": 2048 + }, + { + "id": "5:6826067441618715582", + "name": "name", + "type": 9 + }, + { + "id": "6:2882884329284383374", + "name": "startedAt", + "indexId": "32:3623862218936235916", + "type": 6, + "flags": 8 + }, + { + "id": "7:2975769613296758001", + "name": "completedAt", + "type": 6 + }, + { + "id": "8:2781354287316497412", + "name": "durationSeconds", + "type": 5 + }, + { + "id": "9:987891453695385349", + "name": "rating", + "type": 8 + }, + { + "id": "10:4394575519908125655", + "name": "notes", + "type": 9 + }, + { + "id": "11:3528795408108132268", + "name": "status", + "indexId": "33:99442323983545284", + "type": 9, + "flags": 2048 + }, + { + "id": "12:9031113332705334533", + "name": "createdAt", + "type": 6 + }, + { + "id": "13:6370380062255031895", + "name": "updatedAt", + "type": 6 + }, + { + "id": "14:1794134261713451605", + "name": "planId", + "indexId": "34:7150481635002111418", + "type": 11, + "flags": 520, + "relationTarget": "PlanEntity" + }, + { + "id": "15:2664564091801544337", + "name": "planDayId", + "indexId": "35:6005115109596506198", + "type": 11, + "flags": 520, + "relationTarget": "PlanDayEntity" + }, + { + "id": "16:1999541678396910812", + "name": "imported", + "type": 1 + } + ], + "relations": [] + }, + { + "id": "11:4741913948774127407", + "lastPropertyId": "11:621276958343896092", + "name": "WorkoutExerciseEntity", + "properties": [ + { + "id": "1:7464516990309689993", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:3491438182960818812", + "name": "uid", + "indexId": "36:421058826231474802", + "type": 9, + "flags": 2080 + }, + { + "id": "3:6887240933183881732", + "name": "workoutUid", + "indexId": "37:3363423963746750088", + "type": 9, + "flags": 2048 + }, + { + "id": "4:7587133369804765978", + "name": "exerciseUid", + "indexId": "38:7985944065281696022", + "type": 9, + "flags": 2048 + }, + { + "id": "5:8183794936877277097", + "name": "orderIndex", + "indexId": "39:7269429662349370187", + "type": 5, + "flags": 8 + }, + { + "id": "6:2624899628615480306", + "name": "supersetGroup", + "type": 9 + }, + { + "id": "7:7306694230480957983", + "name": "notes", + "type": 9 + }, + { + "id": "8:3203356354807555103", + "name": "createdAt", + "type": 6 + }, + { + "id": "9:5230424591224576166", + "name": "updatedAt", + "type": 6 + }, + { + "id": "10:2116151153414940385", + "name": "workoutId", + "indexId": "40:3944651843168485357", + "type": 11, + "flags": 520, + "relationTarget": "WorkoutEntity" + }, + { + "id": "11:621276958343896092", + "name": "exerciseId", + "indexId": "41:6525490956396544282", + "type": 11, + "flags": 520, + "relationTarget": "ExerciseEntity" + } + ], + "relations": [] + }, + { + "id": "12:4360833741220823326", + "lastPropertyId": "17:5498549554904472762", + "name": "WorkoutSetEntity", + "properties": [ + { + "id": "1:4626736428916203101", + "name": "objectBoxId", + "type": 6, + "flags": 1 + }, + { + "id": "2:5346374336183447602", + "name": "uid", + "indexId": "42:2131922783263931925", + "type": 9, + "flags": 2080 + }, + { + "id": "3:5618714557746405252", + "name": "workoutExerciseUid", + "indexId": "43:1920467783060483230", + "type": 9, + "flags": 2048 + }, + { + "id": "4:7019894755280663324", + "name": "setIndex", + "indexId": "44:1947623668304261528", + "type": 5, + "flags": 8 + }, + { + "id": "5:8578473842864728696", + "name": "weight", + "type": 8 + }, + { + "id": "6:8210998745392447185", + "name": "reps", + "type": 8 + }, + { + "id": "7:1383989929661473426", + "name": "rpe", + "type": 8 + }, + { + "id": "8:8319954115125233190", + "name": "rir", + "type": 8 + }, + { + "id": "9:2478622815753987363", + "name": "restSeconds", + "type": 5 + }, + { + "id": "10:2393261358309395291", + "name": "isWarmup", + "indexId": "45:2655474054443381086", + "type": 1, + "flags": 8 + }, + { + "id": "11:2789832380678253136", + "name": "isDropset", + "type": 1 + }, + { + "id": "12:600594436099191511", + "name": "isAmrap", + "type": 1 + }, + { + "id": "13:562173916802785475", + "name": "toFailure", + "type": 1 + }, + { + "id": "14:7771050552602058712", + "name": "completedAt", + "type": 6 + }, + { + "id": "15:5045205436875128579", + "name": "createdAt", + "type": 6 + }, + { + "id": "16:3655167121615541243", + "name": "updatedAt", + "type": 6 + }, + { + "id": "17:5498549554904472762", + "name": "workoutExerciseId", + "indexId": "46:1969267965260840604", + "type": 11, + "flags": 520, + "relationTarget": "WorkoutExerciseEntity" + } + ], + "relations": [] + }, + { + "id": "13:872393084237455310", + "lastPropertyId": "13:8058996708874307559", + "name": "GamificationProfileEntity", + "properties": [ + { + "id": "1:4420681973009124567", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:5581856577428156502", + "name": "offlineUserId", + "indexId": "47:7996134452892434897", + "type": 9, + "flags": 2080 + }, + { + "id": "3:3439692452007655530", + "name": "totalXp", + "type": 6 + }, + { + "id": "4:8269480094590442427", + "name": "level", + "type": 5 + }, + { + "id": "5:6560246734718259827", + "name": "xpInLevel", + "type": 6 + }, + { + "id": "6:3346618059051021487", + "name": "rank", + "type": 9 + }, + { + "id": "7:963411976398449338", + "name": "activeTitle", + "type": 9 + }, + { + "id": "8:8288198492989634710", + "name": "statsJson", + "type": 9 + }, + { + "id": "9:63936376127024961", + "name": "weeklyXp", + "type": 5 + }, + { + "id": "10:8460930991603249917", + "name": "weeklyXpResetWeek", + "type": 9 + }, + { + "id": "11:7666440753031599367", + "name": "streakWeeks", + "type": 5 + }, + { + "id": "12:6171387056460869585", + "name": "makeupCompletionsJson", + "type": 9 + }, + { + "id": "13:8058996708874307559", + "name": "unlockedBadges", + "type": 9 + } + ], + "relations": [] + }, + { + "id": "15:3303933602031657554", + "lastPropertyId": "19:6205034933046485444", + "name": "AthleteCalibrationEntity", + "properties": [ + { + "id": "1:3827532953057363661", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:5078454025712408421", + "name": "offlineUserId", + "indexId": "48:2797616992507402666", + "type": 9, + "flags": 2080 + }, + { + "id": "3:7536905653511905638", + "name": "trainingAgeMonths", + "type": 5 + }, + { + "id": "4:770717731337440208", + "name": "firstVerifiedSessionAt", + "type": 6 + }, + { + "id": "5:8006972148803772392", + "name": "weightUnit", + "type": 9 + }, + { + "id": "6:2990630982597146260", + "name": "bodyweightKg", + "type": 8 + }, + { + "id": "7:936944022977183748", + "name": "goalMode", + "type": 9 + }, + { + "id": "8:63072361060061501", + "name": "weeklyGoalDays", + "type": 5 + }, + { + "id": "9:1287460578734026252", + "name": "importedHistory", + "type": 1 + }, + { + "id": "10:5524069434125522240", + "name": "confidence", + "type": 8 + }, + { + "id": "11:6581969964691433784", + "name": "updatedAt", + "type": 6 + }, + { + "id": "12:4741176203616204863", + "name": "hasPastTraining", + "type": 1 + }, + { + "id": "13:5813023527605646507", + "name": "hasGymAccess", + "type": 1 + }, + { + "id": "14:1159119518610722754", + "name": "baselinePushups", + "type": 5 + }, + { + "id": "15:7164010130302322246", + "name": "baselinePullups", + "type": 5 + }, + { + "id": "16:4733081696383155826", + "name": "baselineBenchKg", + "type": 5 + }, + { + "id": "17:2469407931951061224", + "name": "baselineLatPulldownKg", + "type": 5 + }, + { + "id": "18:5488276568647307231", + "name": "baselineMileRunSeconds", + "type": 5 + }, + { + "id": "19:6205034933046485444", + "name": "historicalTrainingDaysPerWeek", + "type": 5 + } + ], + "relations": [] + }, + { + "id": "16:5759065875517646429", + "lastPropertyId": "12:4155430192547483927", + "name": "IronLedgerEventEntity", + "properties": [ + { + "id": "1:211267648957640289", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:7891121349817722156", + "name": "eventId", + "indexId": "49:3320730592556355093", + "type": 9, + "flags": 2080 + }, + { + "id": "3:422406808856606259", + "name": "sourceType", + "indexId": "50:1912751806984890698", + "type": 9, + "flags": 2048 + }, + { + "id": "4:8409752256982048584", + "name": "sourceId", + "indexId": "51:9104188331982479815", + "type": 9, + "flags": 2048 + }, + { + "id": "5:4871368561614387169", + "name": "eventKind", + "indexId": "52:4130863557055800333", + "type": 9, + "flags": 2048 + }, + { + "id": "6:1782006764642706504", + "name": "occurredAt", + "indexId": "53:3101713585028288796", + "type": 6, + "flags": 8 + }, + { + "id": "7:3581981213273908444", + "name": "xpDelta", + "type": 5 + }, + { + "id": "8:4343578316348105", + "name": "statDeltasJson", + "type": 9 + }, + { + "id": "9:3755353096104464687", + "name": "trustScore", + "type": 8 + }, + { + "id": "10:4028109913350867797", + "name": "fingerprint", + "indexId": "54:996889235122584713", + "type": 9, + "flags": 2048 + }, + { + "id": "11:759123915799413798", + "name": "metadataJson", + "type": 9 + }, + { + "id": "12:4155430192547483927", + "name": "invalidated", + "indexId": "55:7679737323583470378", + "type": 1, + "flags": 8 + } + ], + "relations": [] + } + ], + "lastEntityId": "16:5759065875517646429", + "lastIndexId": "55:7679737323583470378", + "lastRelationId": "0:0", + "lastSequenceId": "0:0", + "modelVersion": 5, + "modelVersionParserMinimum": 5, + "retiredEntityUids": [ + 885356565461564884 + ], + "retiredIndexUids": [], + "retiredPropertyUids": [ + 8743783797112242118, + 6362880859759003247, + 6216156919604802650, + 1447604901136741389, + 6825076196334837396, + 1279230704031176310, + 7102108765480323005, + 4485041613321335939, + 1192629098759155910, + 7199381384699893764, + 353580902206095814 + ], + "retiredRelationUids": [], + "version": 1 +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..fe76cd3 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,109 @@ +# IronLog ProGuard / R8 rules + +# ── ObjectBox ──────────────────────────────────────────────────────────────── +-keep class io.objectbox.** { *; } +-dontwarn io.objectbox.** +# Keep all entity classes and their fields (ObjectBox uses reflection for cursor generation) +-keep @io.objectbox.annotation.Entity class * { *; } +-keepclassmembers @io.objectbox.annotation.Entity class * { *; } + +# ── Kotlin serialization ───────────────────────────────────────────────────── +-keep @kotlinx.serialization.Serializable class * { *; } +-keepclassmembers @kotlinx.serialization.Serializable class * { *; } +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt +-keepclassmembers class kotlinx.serialization.json.** { *** Companion; } +-keepclasseswithmembers class kotlinx.serialization.** { *; } + +# ── Kotlin ─────────────────────────────────────────────────────────────────── +-keep class kotlin.Metadata { *; } +-keepattributes Signature, RuntimeVisibleAnnotations, AnnotationDefault + +# ── Ktor ───────────────────────────────────────────────────────────────────── +-keep class io.ktor.** { *; } +-dontwarn io.ktor.** +-keep class kotlinx.coroutines.** { *; } + +# ── Security crypto (EncryptedSharedPreferences) ───────────────────────────── +-keep class androidx.security.crypto.** { *; } +-dontwarn androidx.security.crypto.** + +# ── Gson ───────────────────────────────────────────────────────────────────── +-keep class com.google.gson.** { *; } +-keepattributes *Annotation* +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# ── ML Kit Barcode / CameraX ───────────────────────────────────────────────── +-keep class com.google.mlkit.** { *; } +-dontwarn com.google.mlkit.** +-keep class androidx.camera.** { *; } + +# ── Glance widgets ─────────────────────────────────────────────────────────── +-keep class androidx.glance.** { *; } +-dontwarn androidx.glance.** + +# ── Compose ────────────────────────────────────────────────────────────────── +-keep class androidx.compose.** { *; } +-dontwarn androidx.compose.** +-keepclassmembers class * extends androidx.lifecycle.ViewModel { (...); } + +# ── Lottie ─────────────────────────────────────────────────────────────────── +-keep class com.airbnb.lottie.** { *; } +-dontwarn com.airbnb.lottie.** + +# ── Coil ───────────────────────────────────────────────────────────────────── +-keep class coil3.** { *; } +-dontwarn coil3.** + +# ── Vico charts ────────────────────────────────────────────────────────────── +-keep class com.patrykandpatrick.vico.** { *; } +-dontwarn com.patrykandpatrick.vico.** + +# ── Timber ─────────────────────────────────────────────────────────────────── +-keep class timber.log.** { *; } + +# ── Health Connect ─────────────────────────────────────────────────────────── +-keep class androidx.health.connect.** { *; } +-dontwarn androidx.health.connect.** + +# ── Haze blur ──────────────────────────────────────────────────────────────── +-keep class dev.chrisbanes.haze.** { *; } +-dontwarn dev.chrisbanes.haze.** + +# ── Gemini Nano AICore ─────────────────────────────────────────────────────── +-keep class com.google.ai.edge.** { *; } +-dontwarn com.google.ai.edge.** + +# ── WorkManager ────────────────────────────────────────────────────────────── +-keep class * extends androidx.work.Worker { *; } +-keep class * extends androidx.work.CoroutineWorker { *; } +-keep class * extends androidx.work.ListenableWorker { *; } +-keepclassmembers class * extends androidx.work.ListenableWorker { + public (android.content.Context, androidx.work.WorkerParameters); +} + +# ── QR codec ───────────────────────────────────────────────────────────────── +-keep class io.github.g0dkar.** { *; } +-dontwarn io.github.g0dkar.** + +# ── App data models (backup/restore JSON mapping via Gson/kotlinx) ──────────── +-keep class com.ironlog.app.data.model.** { *; } +-keep class com.ironlog.app.ui.model.** { *; } +-keep class com.ironlog.app.domain.gamification.** { *; } +-keep class com.ironlog.app.domain.badges.** { *; } + +# ── Keep enums ─────────────────────────────────────────────────────────────── +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +# ── Suppress common harmless warnings ──────────────────────────────────────── +-dontwarn org.bouncycastle.** +-dontwarn org.conscrypt.** +-dontwarn org.openjsse.** +-dontwarn sun.misc.** +-dontwarn javax.annotation.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2cc0462 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/exerciseLibrary.json b/app/src/main/assets/exerciseLibrary.json new file mode 100644 index 0000000..73b9ee8 --- /dev/null +++ b/app/src/main/assets/exerciseLibrary.json @@ -0,0 +1,61530 @@ +{ + "meta": { + "schemaVersion": "2.0.0", + "generatedAt": "2026-04-25T02:51:20.759472+00:00", + "totalExercises": 1731, + "replacesSchemaVersion": "1.0.0", + "sourceLibraries": [ + "ironlog-original", + "ironlog-additions", + "codex-source-verified" + ], + "appEquipmentValues": [ + "Barbell", + "Dumbbell", + "Cable", + "Machine", + "Bodyweight", + "Band", + "Kettlebell", + "Conditioning", + "Other" + ], + "detailedEquipmentValues": [ + "Bodyweight", + "Barbell", + "Dumbbell", + "Cable", + "Machine", + "Smith Machine", + "Kettlebell", + "EZ Bar", + "Resistance Band", + "Medicine Ball", + "Stability Ball", + "Suspension/TRX", + "Rings", + "Pull-Up Bar", + "Dip Bars", + "Bench", + "Box/Platform", + "Landmine", + "Plate", + "Sled", + "Battle Rope", + "Rope", + "Sandbag", + "Trap Bar", + "Foam Roller", + "Cardio Machine", + "Other" + ], + "trackingTypeValues": [ + "weight_reps", + "duration", + "duration_distance" + ], + "categoryValues": [ + "strength", + "cardio", + "mobility", + "bodyweight", + "olympic" + ], + "movementPatternValues": [ + "push", + "pull", + "hinge", + "squat", + "carry", + "rotation", + "isometric", + "locomotion" + ], + "difficultyValues": [ + "beginner", + "intermediate", + "advanced" + ], + "muscleValues": [ + "Abdominals", + "Abductors", + "Adductors", + "Biceps", + "Calves", + "Chest", + "Conditioning", + "Core", + "Forearms", + "Full Body", + "Glutes", + "Hamstrings", + "Lats", + "Lower back", + "Middle back", + "Mobility", + "Neck", + "Obliques", + "Quadriceps", + "Shoulders", + "Traps", + "Triceps" + ] + }, + "exercises": [ + { + "id": "34_situp", + "name": "3/4 Sit-Up", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ab_crunch_machine", + "name": "Ab Crunch Machine", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "advanced_kettlebell_windmill", + "name": "Advanced Kettlebell Windmill", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Glutes", + "Hamstrings" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_influencer_common" + ] + }, + { + "id": "alternate_heel_touchers", + "name": "Alternate Heel Touchers", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_ab_rollout", + "name": "Barbell Ab Rollout", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Glutes" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": true, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_ab_rollout__on_knees", + "name": "Barbell Ab Rollout - on Knees", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Glutes" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": true, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [ + "Barbell Ab Rollout - On Knees" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_rollout_from_bench", + "name": "Barbell Rollout From Bench", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Glutes" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": true, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [ + "Barbell Rollout from Bench" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_side_bend", + "name": "Barbell Side Bend", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bent_press", + "name": "Bent Press", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bentknee_hip_raise", + "name": "Bent-Knee Hip Raise", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bosu_ball_cable_crunch_with_side_bends", + "name": "Bosu Ball Cable Crunch With Side Bends", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bottoms_up", + "name": "Bottoms up", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [ + "Bottoms Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "buttups", + "name": "Butt-Ups", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_crunch", + "name": "Cable Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_judo_flip", + "name": "Cable Judo Flip", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_reverse_crunch", + "name": "Cable Reverse Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_russian_twists", + "name": "Cable Russian Twists", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_seated_crunch", + "name": "Cable Seated Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cocoons", + "name": "Cocoons", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "crossbody_crunch", + "name": "Cross-Body Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "crunch__hands_overhead", + "name": "Crunch - Hands Overhead", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "crunch__legs_on_exercise_ball", + "name": "Crunch - Legs on Exercise Ball", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Stability Ball", + "apparatus": [ + "Stability Ball" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [ + "Crunch - Legs On Exercise Ball" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "crunches", + "name": "Crunches", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dead_bug", + "name": "Dead Bug", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_crunch", + "name": "Decline Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_oblique_crunch", + "name": "Decline Oblique Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_reverse_crunch", + "name": "Decline Reverse Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "double_kettlebell_windmill", + "name": "Double Kettlebell Windmill", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Glutes", + "Hamstrings" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_influencer_common" + ] + }, + { + "id": "dumbbell_side_bend", + "name": "Dumbbell Side Bend", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "elbow_to_knee", + "name": "Elbow to Knee", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "exercise_ball_crunch", + "name": "Exercise Ball Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Stability Ball", + "apparatus": [ + "Stability Ball" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "exercise_ball_pullin", + "name": "Exercise Ball Pull-In", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Stability Ball", + "apparatus": [ + "Stability Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "flat_bench_leg_pullin", + "name": "Flat Bench Leg Pull-In", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "flat_bench_lying_leg_raise", + "name": "Flat Bench Lying Leg Raise", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "frog_situps", + "name": "Frog Sit-Ups", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "gorilla_chincrunch", + "name": "Gorilla Chin/Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [ + "Gorilla Crunch" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hanging_leg_raise", + "name": "Hanging Leg Raise", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hanging_pike", + "name": "Hanging Pike", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "jackknife_situp", + "name": "Jackknife Sit-up", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [ + "Jackknife Sit-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "janda_situp", + "name": "Janda Sit-up", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [ + "Janda Sit-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_figure_8", + "name": "Kettlebell Figure 8", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_pass_between_the_legs", + "name": "Kettlebell Pass Between the Legs", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [ + "Kettlebell Pass Between The Legs" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_windmill", + "name": "Kettlebell Windmill", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Glutes", + "Hamstrings" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_influencer_common" + ] + }, + { + "id": "kneehip_raise_on_parallel_bars", + "name": "Knee/Hip Raise on Parallel Bars", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Dip Bars", + "apparatus": [ + "Dip Bars" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [ + "Knee/Hip Raise On Parallel Bars" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_exrx" + ] + }, + { + "id": "kneeling_cable_crunch_with_alternating_oblique_twists", + "name": "Kneeling Cable Crunch With Alternating Oblique Twists", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "leg_pullin", + "name": "Leg Pull-In", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lower_back_curl", + "name": "Lower Back Curl", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "medicine_ball_full_twist", + "name": "Medicine Ball Full Twist", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Obliques" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "oblique_crunches", + "name": "Oblique Crunches", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "oblique_crunches__on_the_floor", + "name": "Oblique Crunches - on the Floor", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [ + "Oblique Crunches - On The Floor" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_highpulley_cable_side_bends", + "name": "One-Arm High-Pulley Cable Side Bends", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_medicine_ball_slam", + "name": "One-Arm Medicine Ball Slam", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "otisup", + "name": "Otis-up", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [ + "Otis-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "overhead_stretch", + "name": "Overhead Stretch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pallof_press", + "name": "Pallof Press", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pallof_press_with_rotation", + "name": "Pallof Press With Rotation", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Obliques" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plank", + "name": "Plank", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plate_twist", + "name": "Plate Twist", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Obliques" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "press_situp", + "name": "Press Sit-up", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [ + "Press Sit-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_crunch", + "name": "Reverse Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rope_crunch", + "name": "Rope Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "russian_twist", + "name": "Russian Twist", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Obliques" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "scissor_kick", + "name": "Scissor Kick", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_barbell_twist", + "name": "Seated Barbell Twist", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Obliques" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_flat_bench_leg_pullin", + "name": "Seated Flat Bench Leg Pull-In", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_leg_tucks", + "name": "Seated Leg Tucks", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_overhead_stretch", + "name": "Seated Overhead Stretch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_bridge", + "name": "Side Bridge", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_jackknife", + "name": "Side Jackknife", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "situp", + "name": "Sit-up", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [ + "Sit-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sledgehammer_swings", + "name": "Sledgehammer Swings", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_hip_raise", + "name": "Smith Machine Hip Raise", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "spell_caster", + "name": "Spell Caster", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "spider_crawl", + "name": "Spider Crawl", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_cable_lift", + "name": "Standing Cable Lift", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Obliques" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_cable_wood_chop", + "name": "Standing Cable Wood Chop", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Obliques" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_lateral_stretch", + "name": "Standing Lateral Stretch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_rope_crunch", + "name": "Standing Rope Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stomach_vacuum", + "name": "Stomach Vacuum", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "supine_onearm_overhead_throw", + "name": "Supine One-Arm Overhead Throw", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "supine_twoarm_overhead_throw", + "name": "Supine Two-Arm Overhead Throw", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "suspended_fallout", + "name": "Suspended Fallout", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Glutes", + "Lower back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "suspended_reverse_crunch", + "name": "Suspended Reverse Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "toe_touchers", + "name": "Toe Touchers", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "torso_rotation", + "name": "Torso Rotation", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Obliques" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tuck_crunch", + "name": "Tuck Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "weighted_ball_side_bend", + "name": "Weighted Ball Side Bend", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "weighted_crunches", + "name": "Weighted Crunches", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "weighted_situps__with_bands", + "name": "Weighted Sit-Ups - With Bands", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wind_sprints", + "name": "Wind Sprints", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hip_circles_prone", + "name": "Hip Circles (prone)", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "it_band_and_glute_stretch", + "name": "IT Band and Glute Stretch", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "iliotibial_tractsmr", + "name": "Iliotibial Tract-SMR", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Foam Roller", + "apparatus": [ + "Foam Roller" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_crossover", + "name": "Lying Crossover", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "monster_walk", + "name": "Monster Walk", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_hip_circles", + "name": "Standing Hip Circles", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "thigh_abductor", + "name": "Thigh Abductor", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "windmills", + "name": "Windmills", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "adductor", + "name": "Adductor", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Glutes", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "adductorgroin", + "name": "Adductor/Groin", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [ + "Groin Stretch", + "Adductor Stretch" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "band_hip_adductions", + "name": "Band Hip Adductions", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Glutes", + "Quadriceps", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "carioca_quick_step", + "name": "Carioca Quick Step", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Glutes", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "groin_and_back_stretch", + "name": "Groin and Back Stretch", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Glutes", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "groiners", + "name": "Groiners", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Glutes", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lateral_bound", + "name": "Lateral Bound", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Glutes", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lateral_box_jump", + "name": "Lateral Box Jump", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Glutes", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Box/Platform", + "apparatus": [ + "Box" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lateral_cone_hops", + "name": "Lateral Cone Hops", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Glutes", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_bent_leg_groin", + "name": "Lying Bent Leg Groin", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Glutes", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_leg_raises", + "name": "Side Leg Raises", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Glutes", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_lying_groin_stretch", + "name": "Side Lying Groin Stretch", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Glutes", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "thigh_adductor", + "name": "Thigh Adductor", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Glutes", + "Quadriceps", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "alternate_hammer_curl", + "name": "Alternate Hammer Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "alternate_incline_dumbbell_curl", + "name": "Alternate Incline Dumbbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_curl", + "name": "Barbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_curls_lying_against_an_incline", + "name": "Barbell Curls Lying Against An Incline", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "brachialissmr", + "name": "Brachialis-SMR", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Foam Roller", + "apparatus": [ + "Foam Roller" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_hammer_curls__rope_attachment", + "name": "Cable Hammer Curls - Rope Attachment", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_preacher_curl", + "name": "Cable Preacher Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "closegrip_ez_bar_curl", + "name": "Close-Grip EZ Bar Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "EZ Bar", + "apparatus": [ + "EZ Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "closegrip_ezbar_curl_with_band", + "name": "Close-Grip EZ-Bar Curl With Band", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "EZ Bar", + "apparatus": [ + "EZ Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Close-Grip EZ-Bar Curl with Band" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "closegrip_standing_barbell_curl", + "name": "Close-Grip Standing Barbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cross_body_hammer_curl", + "name": "Cross Body Hammer Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "drag_curl", + "name": "Drag Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_alternate_bicep_curl", + "name": "Dumbbell Alternate Bicep Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_bicep_curl", + "name": "Dumbbell Bicep Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_prone_incline_curl", + "name": "Dumbbell Prone Incline Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ezbar_curl", + "name": "EZ-Bar Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "EZ Bar", + "apparatus": [ + "EZ Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "flexor_incline_dumbbell_curls", + "name": "Flexor Incline Dumbbell Curls", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hammer_curls", + "name": "Hammer Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Hammer Curls" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "high_cable_curls", + "name": "High Cable Curls", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_dumbbell_curl", + "name": "Incline Dumbbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_inner_biceps_curl", + "name": "Incline Inner Biceps Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_cable_curl", + "name": "Lying Cable Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_closegrip_bar_curl_on_high_pulley", + "name": "Lying Close-Grip Bar Curl on High Pulley", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Lying Close-Grip Bar Curl On High Pulley" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_high_bench_barbell_curl", + "name": "Lying High Bench Barbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_supine_dumbbell_curl", + "name": "Lying Supine Dumbbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_bicep_curl", + "name": "Machine Bicep Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "one_arm_dumbbell_preacher_curl", + "name": "One Arm Dumbbell Preacher Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "overhead_cable_curl", + "name": "Overhead Cable Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "preacher_curl", + "name": "Preacher Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "EZ Bar", + "apparatus": [ + "EZ Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "preacher_hammer_dumbbell_curl", + "name": "Preacher Hammer Dumbbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_barbell_curl", + "name": "Reverse Barbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_barbell_preacher_curls", + "name": "Reverse Barbell Preacher Curls", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_cable_curl", + "name": "Reverse Cable Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_plate_curls", + "name": "Reverse Plate Curls", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_biceps", + "name": "Seated Biceps", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_closegrip_concentration_barbell_curl", + "name": "Seated Close-Grip Concentration Barbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_dumbbell_curl", + "name": "Seated Dumbbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_dumbbell_inner_biceps_curl", + "name": "Seated Dumbbell Inner Biceps Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "spider_curl", + "name": "Spider Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "EZ Bar", + "apparatus": [ + "EZ Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_biceps_cable_curl", + "name": "Standing Biceps Cable Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_biceps_stretch", + "name": "Standing Biceps Stretch", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_concentration_curl", + "name": "Standing Concentration Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_dumbbell_reverse_curl", + "name": "Standing Dumbbell Reverse Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_innerbiceps_curl", + "name": "Standing Inner-Biceps Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_onearm_cable_curl", + "name": "Standing One-Arm Cable Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_onearm_dumbbell_curl_over_incline_bench", + "name": "Standing One-Arm Dumbbell Curl Over Incline Bench", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "twoarm_dumbbell_preacher_curl", + "name": "Two-Arm Dumbbell Preacher Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "widegrip_standing_barbell_curl", + "name": "Wide-Grip Standing Barbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "zottman_curl", + "name": "Zottman Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "zottman_preacher_curl", + "name": "Zottman Preacher Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "EZ Bar", + "apparatus": [ + "EZ Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ankle_circles", + "name": "Ankle Circles", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "anterior_tibialissmr", + "name": "Anterior Tibialis-SMR", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Foam Roller", + "apparatus": [ + "Foam Roller" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "balance_board", + "name": "Balance Board", + "primaryMuscle": "Mobility", + "primaryMuscles": [ + "Mobility" + ], + "secondaryMuscles": [ + "Calves", + "Core" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Balance Board" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_seated_calf_raise", + "name": "Barbell Seated Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "calf_press", + "name": "Calf Press", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "calf_press_on_the_leg_press_machine", + "name": "Calf Press on the Leg Press Machine", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Leg Press Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [ + "Calf Press On The Leg Press Machine" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "calf_raise_on_a_dumbbell", + "name": "Calf Raise on A Dumbbell", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Calf Raise On A Dumbbell" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "calf_raises__with_bands", + "name": "Calf Raises - With Bands", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "calf_stretch_elbows_against_wall", + "name": "Calf Stretch Elbows Against Wall", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Wall" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "calf_stretch_hands_against_wall", + "name": "Calf Stretch Hands Against Wall", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Wall" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "calvessmr", + "name": "Calves-SMR", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Foam Roller", + "apparatus": [ + "Foam Roller" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "donkey_calf_raises", + "name": "Donkey Calf Raises", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_seated_oneleg_calf_raise", + "name": "Dumbbell Seated One-Leg Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "footsmr", + "name": "Foot-SMR", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Foam Roller", + "apparatus": [ + "Foam Roller" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "knee_circles", + "name": "Knee Circles", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "peroneals_stretch", + "name": "Peroneals Stretch", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "peronealssmr", + "name": "Peroneals-SMR", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Foam Roller", + "apparatus": [ + "Foam Roller" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "posterior_tibialis_stretch", + "name": "Posterior Tibialis Stretch", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rocking_standing_calf_raise", + "name": "Rocking Standing Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_calf_raise", + "name": "Seated Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_calf_stretch", + "name": "Seated Calf Stretch", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_calf_raise", + "name": "Smith Machine Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_reverse_calf_raises", + "name": "Smith Machine Reverse Calf Raises", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_barbell_calf_raise", + "name": "Standing Barbell Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_calf_raises", + "name": "Standing Calf Raises", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_dumbbell_calf_raise", + "name": "Standing Dumbbell Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_gastrocnemius_calf_stretch", + "name": "Standing Gastrocnemius Calf Stretch", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_soleus_and_achilles_stretch", + "name": "Standing Soleus and Achilles Stretch", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Standing Soleus And Achilles Stretch" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "alternating_floor_press", + "name": "Alternating Floor Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "around_the_worlds", + "name": "Around the Worlds", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Around The Worlds" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_bench_press__medium_grip", + "name": "Barbell Bench Press - Medium Grip", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_guillotine_bench_press", + "name": "Barbell Guillotine Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_incline_bench_press__medium_grip", + "name": "Barbell Incline Bench Press - Medium Grip", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "behind_head_chest_stretch", + "name": "Behind Head Chest Stretch", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bench_press__with_bands", + "name": "Bench Press - With Bands", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bentarm_dumbbell_pullover", + "name": "Bent-Arm Dumbbell Pullover", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bodyweight_flyes", + "name": "Bodyweight Flyes", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "butterfly", + "name": "Butterfly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_chest_press", + "name": "Cable Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_crossover", + "name": "Cable Crossover", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_iron_cross", + "name": "Cable Iron Cross", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chain_press", + "name": "Chain Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chest_and_front_of_shoulder_stretch", + "name": "Chest and Front of Shoulder Stretch", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Chest And Front Of Shoulder Stretch" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chest_push_multiple_response", + "name": "Chest Push (multiple Response)", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Chest Push (multiple response)" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chest_push_single_response", + "name": "Chest Push (single Response)", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Chest Push (single response)" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chest_push_from_3_point_stance", + "name": "Chest Push From 3 Point Stance", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Chest Push from 3 point stance" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chest_push_with_run_release", + "name": "Chest Push With Run Release", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [ + "Chest Push with Run Release" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chest_stretch_on_stability_ball", + "name": "Chest Stretch on Stability Ball", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Stability Ball", + "apparatus": [ + "Stability Ball" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "clock_pushup", + "name": "Clock Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Clock Push-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cross_over__with_bands", + "name": "Cross Over - With Bands", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_barbell_bench_press", + "name": "Decline Barbell Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_dumbbell_bench_press", + "name": "Decline Dumbbell Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_dumbbell_flyes", + "name": "Decline Dumbbell Flyes", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_pushup", + "name": "Decline Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Decline Push-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_smith_press", + "name": "Decline Smith Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dips__chest_version", + "name": "Dips - Chest Version", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Dip Bars", + "apparatus": [ + "Dip Bars" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "drop_push", + "name": "Drop Push", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_bench_press", + "name": "Dumbbell Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_bench_press_with_neutral_grip", + "name": "Dumbbell Bench Press With Neutral Grip", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Dumbbell Bench Press with Neutral Grip" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_flyes", + "name": "Dumbbell Flyes", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dynamic_chest_stretch", + "name": "Dynamic Chest Stretch", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "elbows_back", + "name": "Elbows Back", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "extended_range_onearm_kettlebell_floor_press", + "name": "Extended Range One-Arm Kettlebell Floor Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "flat_bench_cable_flyes", + "name": "Flat Bench Cable Flyes", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "forward_drag_with_press", + "name": "Forward Drag With Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Forward Drag with Press" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_raise_and_pullover", + "name": "Front Raise and Pullover", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Front Raise And Pullover" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hammer_grip_incline_db_bench_press", + "name": "Hammer Grip Incline DB Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "heavy_bag_thrust", + "name": "Heavy Bag Thrust", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Triceps", + "Core" + ], + "equipment": "Conditioning", + "equipmentDetail": "Other", + "apparatus": [ + "Heavy Bag" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_cable_chest_press", + "name": "Incline Cable Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_cable_flye", + "name": "Incline Cable Flye", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_dumbbell_bench_with_palms_facing_in", + "name": "Incline Dumbbell Bench With Palms Facing In", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_dumbbell_flyes", + "name": "Incline Dumbbell Flyes", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_dumbbell_flyes__with_a_twist", + "name": "Incline Dumbbell Flyes - With A Twist", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_dumbbell_press", + "name": "Incline Dumbbell Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_pushup", + "name": "Incline Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Incline Push-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_pushup_depth_jump", + "name": "Incline Push-up Depth Jump", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [ + "Incline Push-Up Depth Jump" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_pushup_medium", + "name": "Incline Push-up Medium", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Incline Push-Up Medium" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_pushup_reverse_grip", + "name": "Incline Push-up Reverse Grip", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Incline Push-Up Reverse Grip" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_pushup_wide", + "name": "Incline Push-up Wide", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Incline Push-Up Wide" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "isometric_chest_squeezes", + "name": "Isometric Chest Squeezes", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "isometric_wipers", + "name": "Isometric Wipers", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "legover_floor_press", + "name": "Leg-Over Floor Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "leverage_chest_press", + "name": "Leverage Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "leverage_decline_chest_press", + "name": "Leverage Decline Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "leverage_incline_chest_press", + "name": "Leverage Incline Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "low_cable_crossover", + "name": "Low Cable Crossover", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_bench_press", + "name": "Machine Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "medicine_ball_chest_pass", + "name": "Medicine Ball Chest Pass", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "neck_press", + "name": "Neck Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "one_arm_dumbbell_bench_press", + "name": "One Arm Dumbbell Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_flat_bench_dumbbell_flye", + "name": "One-Arm Flat Bench Dumbbell Flye", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_kettlebell_floor_press", + "name": "One-Arm Kettlebell Floor Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plyo_kettlebell_pushups", + "name": "Plyo Kettlebell Push-Ups", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [ + "Plyo Kettlebell Pushups", + "Press-Up", + "Pushup" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_exrx", + "source_wger" + ] + }, + { + "id": "plyo_pushup", + "name": "Plyo Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "push_up_to_side_plank", + "name": "Push-up to Side Plank", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Push Up to Side Plank" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pushup_wide", + "name": "Push-up Wide", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Push-Up Wide" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pushups_with_feet_elevated", + "name": "Push-Ups With Feet Elevated", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Press-Up", + "Pushup" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_exrx", + "source_wger" + ] + }, + { + "id": "pushups_with_feet_on_an_exercise_ball", + "name": "Push-Ups With Feet on An Exercise Ball", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Stability Ball", + "apparatus": [ + "Stability Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Press-Up", + "Push-Ups With Feet On An Exercise Ball", + "Pushup" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_exrx", + "source_wger" + ] + }, + { + "id": "pushups", + "name": "Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Pushups" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "duplicate_candidate" + ] + }, + { + "id": "pushups_close_and_wide_hand_positions", + "name": "Push-Ups (close and Wide Hand Positions)", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Press-Up", + "Pushup", + "Pushups (Close and Wide Hand Positions)" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_exrx", + "source_wger" + ] + }, + { + "id": "singlearm_cable_crossover", + "name": "Single-Arm Cable Crossover", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "singlearm_pushup", + "name": "Single-Arm Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Single-Arm Push-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_bench_press_smith_machine", + "name": "Bench Press - Smith Machine", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_decline_press", + "name": "Smith Machine Decline Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_incline_bench_press_smith_machine", + "name": "Incline Bench Press - Smith Machine", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Smith Machine Incline Bench Press" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_cable_chest_press", + "name": "Standing Cable Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "straightarm_dumbbell_pullover", + "name": "Straight-Arm Dumbbell Pullover", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "suspended_pushup", + "name": "Suspended Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Suspended Push-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "svend_press", + "name": "Svend Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "widegrip_barbell_bench_press", + "name": "Wide-Grip Barbell Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "widegrip_decline_barbell_bench_press", + "name": "Wide-Grip Decline Barbell Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "widegrip_decline_barbell_pullover", + "name": "Wide-Grip Decline Barbell Pullover", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bottomsup_clean_from_the_hang_position", + "name": "Bottoms-up Clean From the Hang Position", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps", + "Glutes", + "Hamstrings", + "Quadriceps", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Bottoms-Up Clean From The Hang Position" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_wrist_curl", + "name": "Cable Wrist Curl", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_lying_pronation", + "name": "Dumbbell Lying Pronation", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_lying_supination", + "name": "Dumbbell Lying Supination", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "farmers_walk", + "name": "Farmer's Walk", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps", + "Traps", + "Abdominals", + "Glutes" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "finger_curls", + "name": "Finger Curls", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kneeling_forearm_stretch", + "name": "Kneeling Forearm Stretch", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "palmsdown_dumbbell_wrist_curl_over_a_bench", + "name": "Palms-down Dumbbell Wrist Curl Over A Bench", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Palms-Down Dumbbell Wrist Curl Over A Bench" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "palmsdown_wrist_curl_over_a_bench", + "name": "Palms-down Wrist Curl Over A Bench", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Palms-Down Wrist Curl Over A Bench" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "palmsup_barbell_wrist_curl_over_a_bench", + "name": "Palms-up Barbell Wrist Curl Over A Bench", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Palms-Up Barbell Wrist Curl Over A Bench" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "palmsup_dumbbell_wrist_curl_over_a_bench", + "name": "Palms-up Dumbbell Wrist Curl Over A Bench", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Palms-Up Dumbbell Wrist Curl Over A Bench" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plate_pinch", + "name": "Plate Pinch", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rickshaw_carry", + "name": "Rickshaw Carry", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps", + "Traps", + "Abdominals", + "Glutes" + ], + "equipment": "Barbell", + "equipmentDetail": "Trap Bar", + "apparatus": [ + "Trap Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_dumbbell_palmsdown_wrist_curl", + "name": "Seated Dumbbell Palms-down Wrist Curl", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Seated Dumbbell Palms-Down Wrist Curl" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_dumbbell_palmsup_wrist_curl", + "name": "Seated Dumbbell Palms-up Wrist Curl", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Seated Dumbbell Palms-Up Wrist Curl" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_onearm_dumbbell_palmsdown_wrist_curl", + "name": "Seated One-Arm Dumbbell Palms-down Wrist Curl", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Seated One-Arm Dumbbell Palms-Down Wrist Curl" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_onearm_dumbbell_palmsup_wrist_curl", + "name": "Seated One-Arm Dumbbell Palms-up Wrist Curl", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Seated One-Arm Dumbbell Palms-Up Wrist Curl" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_palmup_barbell_wrist_curl", + "name": "Seated Palm-up Barbell Wrist Curl", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Seated Palm-Up Barbell Wrist Curl" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_palmsdown_barbell_wrist_curl", + "name": "Seated Palms-down Barbell Wrist Curl", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Seated Palms-Down Barbell Wrist Curl" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_twoarm_palmsup_lowpulley_wrist_curl", + "name": "Seated Two-Arm Palms-up Low-Pulley Wrist Curl", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Seated Two-Arm Palms-Up Low-Pulley Wrist Curl" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_olympic_plate_hand_squeeze", + "name": "Standing Olympic Plate Hand Squeeze", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_palmsup_barbell_behind_the_back_wrist_curl", + "name": "Standing Palms-up Barbell Behind the Back Wrist Curl", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Standing Palms-Up Barbell Behind The Back Wrist Curl" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wrist_circles", + "name": "Wrist Circles", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wrist_roller", + "name": "Wrist Roller", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Wrist Roller" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wrist_rotations_with_straight_bar", + "name": "Wrist Rotations With Straight Bar", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Biceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Wrist Rotations with Straight Bar" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ankle_on_the_knee", + "name": "Ankle on the Knee", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "Ankle On The Knee" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_glute_bridge", + "name": "Barbell Glute Bridge", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_hip_thrust", + "name": "Barbell Hip Thrust", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "butt_lift_bridge", + "name": "Butt Lift (bridge)", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [ + "Butt Lift (Bridge)" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "downward_facing_balance", + "name": "Downward Facing Balance", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "flutter_kicks", + "name": "Flutter Kicks", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "glute_kickback", + "name": "Glute Kickback", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hip_extension_with_bands", + "name": "Hip Extension With Bands", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Hip Extension with Bands" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hip_lift_with_band", + "name": "Hip Lift With Band", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [ + "Hip Lift with Band" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "knee_across_the_body", + "name": "Knee Across the Body", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "Knee Across The Body" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kneeling_jump_squat", + "name": "Kneeling Jump Squat", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kneeling_squat", + "name": "Kneeling Squat", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "leg_lift", + "name": "Leg Lift", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_glute", + "name": "Lying Glute", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "one_knee_to_chest", + "name": "One Knee to Chest", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "One Knee To Chest" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onelegged_cable_kickback", + "name": "One-Legged Cable Kickback", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "physioball_hip_bridge", + "name": "Physioball Hip Bridge", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Stability Ball", + "apparatus": [ + "Stability Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "piriformissmr", + "name": "Piriformis-SMR", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Foam Roller", + "apparatus": [ + "Foam Roller" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pull_through", + "name": "Pull Through", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_glute", + "name": "Seated Glute", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_glute_bridge", + "name": "Single Leg Glute Bridge", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stepup_with_knee_raise", + "name": "Step-up With Knee Raise", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Box/Platform", + "apparatus": [ + "Box" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [ + "Step-up with Knee Raise" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "9090_hamstring", + "name": "90/90 Hamstring", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [ + "90/90 Hamstring Stretch" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "alternating_hang_clean", + "name": "Alternating Hang Clean", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ball_leg_curl", + "name": "Ball Leg Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Stability Ball", + "apparatus": [ + "Stability Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_good_morning", + "name": "Band Good Morning", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_good_morning_pull_through", + "name": "Band Good Morning (pull Through)", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [ + "Band Good Morning (Pull Through)" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "box_jump_multiple_response", + "name": "Box Jump (multiple Response)", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Box/Platform", + "apparatus": [ + "Box" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [ + "Box Jump (Multiple Response)" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "box_skip", + "name": "Box Skip", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Box/Platform", + "apparatus": [ + "Box" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chair_leg_extended_stretch", + "name": "Chair Leg Extended Stretch", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Box/Platform", + "apparatus": [ + "Box" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "clean", + "name": "Clean", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "clean_deadlift", + "name": "Clean Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "double_kettlebell_alternating_hang_clean", + "name": "Double Kettlebell Alternating Hang Clean", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_clean", + "name": "Dumbbell Clean", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "floor_gluteham_raise", + "name": "Floor Glute-Ham Raise", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_box_jump", + "name": "Front Box Jump", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Box/Platform", + "apparatus": [ + "Box" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_leg_raises", + "name": "Front Leg Raises", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "glute_ham_raise", + "name": "Glute Ham Raise", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Calves", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Glute-Ham Developer" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_exrx" + ] + }, + { + "id": "good_morning", + "name": "Good Morning", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "good_morning_off_pins", + "name": "Good Morning Off Pins", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "Good Morning off Pins" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hamstring_stretch", + "name": "Hamstring Stretch", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Rings", + "apparatus": [ + "Rings" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hamstringsmr", + "name": "Hamstring-SMR", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Rings", + "apparatus": [ + "Rings" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hang_snatch", + "name": "Hang Snatch", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hang_snatch__below_knees", + "name": "Hang Snatch - Below Knees", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hanging_bar_good_morning", + "name": "Hanging Bar Good Morning", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hurdle_hops", + "name": "Hurdle Hops", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "inchworm", + "name": "Inchworm", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "intermediate_groin_stretch", + "name": "Intermediate Groin Stretch", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_dead_clean", + "name": "Kettlebell Dead Clean", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_hang_clean", + "name": "Kettlebell Hang Clean", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_onelegged_deadlift", + "name": "Kettlebell One-Legged Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "knee_tuck_jump", + "name": "Knee Tuck Jump", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "legup_hamstring_stretch", + "name": "Leg-up Hamstring Stretch", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Rings", + "apparatus": [ + "Rings" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [ + "Leg-Up Hamstring Stretch" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "linear_3part_start_technique", + "name": "Linear 3-Part Start Technique", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "linear_acceleration_wall_drill", + "name": "Linear Acceleration Wall Drill", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Wall" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lunge_pass_through", + "name": "Lunge Pass Through", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_hamstring", + "name": "Lying Hamstring", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_leg_curls", + "name": "Lying Leg Curls", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "moving_claw_series", + "name": "Moving Claw Series", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "muscle_snatch", + "name": "Muscle Snatch", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "natural_glute_ham_raise", + "name": "Natural Glute Ham Raise", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_kettlebell_clean", + "name": "One-Arm Kettlebell Clean", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_kettlebell_swings", + "name": "One-Arm Kettlebell Swings", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_open_palm_kettlebell_clean", + "name": "One-Arm Open Palm Kettlebell Clean", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "open_palm_kettlebell_clean", + "name": "Open Palm Kettlebell Clean", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "platform_hamstring_slides", + "name": "Platform Hamstring Slides", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Rings", + "apparatus": [ + "Rings" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "power_clean", + "name": "Power Clean", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "power_clean_from_blocks", + "name": "Power Clean From Blocks", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [ + "Power Clean from Blocks" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "power_snatch", + "name": "Power Snatch", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "power_stairs", + "name": "Power Stairs", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "prone_manual_hamstring", + "name": "Prone Manual Hamstring", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "prowler_sprint", + "name": "Prowler Sprint", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_band_sumo_deadlift", + "name": "Reverse Band Sumo Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_hyperextension", + "name": "Reverse Hyperextension", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "romanian_deadlift", + "name": "Romanian Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "RDL" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "romanian_deadlift_from_deficit", + "name": "Romanian Deadlift From Deficit", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "RDL", + "Romanian Deadlift from Deficit" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "runners_stretch", + "name": "Runner's Stretch", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_band_hamstring_curl", + "name": "Seated Band Hamstring Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_floor_hamstring_stretch", + "name": "Seated Floor Hamstring Stretch", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Rings", + "apparatus": [ + "Rings" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_hamstring", + "name": "Seated Hamstring", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_hamstring_and_calf_stretch", + "name": "Seated Hamstring and Calf Stretch", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Rings", + "apparatus": [ + "Rings" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_leg_curl", + "name": "Seated Leg Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_hang_power_clean", + "name": "Smith Machine Hang Power Clean", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_stifflegged_deadlift", + "name": "Smith Machine Stiff-Legged Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "snatch_deadlift", + "name": "Snatch Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "snatch_pull", + "name": "Snatch Pull", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "split_snatch", + "name": "Split Snatch", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals", + "Quadriceps", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "split_squats", + "name": "Split Squats", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_hamstring_and_calf_stretch", + "name": "Standing Hamstring and Calf Stretch", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Rings", + "apparatus": [ + "Rings" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_leg_curl", + "name": "Standing Leg Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_toe_touches", + "name": "Standing Toe Touches", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stifflegged_barbell_deadlift", + "name": "Stiff-Legged Barbell Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stifflegged_dumbbell_deadlift", + "name": "Stiff-Legged Dumbbell Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sumo_deadlift", + "name": "Sumo Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sumo_deadlift_with_bands", + "name": "Sumo Deadlift With Bands", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [ + "Sumo Deadlift with Bands" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sumo_deadlift_with_chains", + "name": "Sumo Deadlift With Chains", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "Sumo Deadlift with Chains" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "the_straddle", + "name": "the Straddle", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "The Straddle" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "upper_backleg_grab", + "name": "Upper Back-Leg Grab", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "vertical_swing", + "name": "Vertical Swing", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wide_stance_stiff_legs", + "name": "Wide Stance Stiff Legs", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "worlds_greatest_stretch", + "name": "World's Greatest Stretch", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_assisted_pullup", + "name": "Band Assisted Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Band Assisted Pull-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bentarm_barbell_pullover", + "name": "Bent-Arm Barbell Pullover", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_incline_pushdown", + "name": "Cable Incline Pushdown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "catch_and_overhead_throw", + "name": "Catch and Overhead Throw", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "chair_lower_back_stretch", + "name": "Chair Lower Back Stretch", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Box/Platform", + "apparatus": [ + "Box" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chinup", + "name": "Chin-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Chin-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "closegrip_front_lat_pulldown", + "name": "Close-Grip Front Lat Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Lat Pulldown Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dynamic_back_stretch", + "name": "Dynamic Back Stretch", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "elevated_cable_rows", + "name": "Elevated Cable Rows", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "full_rangeofmotion_lat_pulldown", + "name": "Full Range-of-Motion Lat Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Lat Pulldown Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Full Range-Of-Motion Lat Pulldown" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "gironda_sternum_chins", + "name": "Gironda Sternum Chins", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kipping_muscle_up", + "name": "Kipping Muscle up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [ + "Kipping Muscle Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kneeling_high_pulley_row", + "name": "Kneeling High Pulley Row", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kneeling_singlearm_high_pulley_row", + "name": "Kneeling Single-Arm High Pulley Row", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "latissimus_dorsismr", + "name": "Latissimus Dorsi-SMR", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Foam Roller", + "apparatus": [ + "Foam Roller" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "leverage_iso_row", + "name": "Leverage Iso Row", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "london_bridges", + "name": "London Bridges", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "muscle_up", + "name": "Muscle up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [ + "Muscle Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "one_arm_against_wall", + "name": "One Arm Against Wall", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Wall" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "one_arm_lat_pulldown", + "name": "One Arm Lat Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Lat Pulldown Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "one_handed_hang", + "name": "One Handed Hang", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "overhead_lat", + "name": "Overhead Lat", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "overhead_slam", + "name": "Overhead Slam", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "pullups", + "name": "Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Pullups" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "duplicate_candidate" + ] + }, + { + "id": "rocky_pullupspulldowns", + "name": "Rocky Pull-Ups/Pulldowns", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back", + "Forearms", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Pullup" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "duplicate_candidate", + "source_wger", + "source_strengthlog" + ] + }, + { + "id": "rope_climb", + "name": "Rope Climb", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Conditioning", + "equipmentDetail": "Rope", + "apparatus": [ + "Rope" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rope_straightarm_pulldown", + "name": "Rope Straight-Arm Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "shotgun_row", + "name": "Shotgun Row", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_to_side_chins", + "name": "Side to Side Chins", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Side To Side Chins" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sidelying_floor_stretch", + "name": "Side-Lying Floor Stretch", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "straightarm_pulldown", + "name": "Straight-Arm Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "underhand_cable_pulldowns", + "name": "Underhand Cable Pulldowns", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "vbar_pulldown", + "name": "V-Bar Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "vbar_pullup", + "name": "V-Bar Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "V-Bar Pullup" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "weighted_pull_ups", + "name": "Weighted Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Weighted Pull Ups" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "widegrip_lat_pulldown", + "name": "Wide-Grip Lat Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Lat Pulldown Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "widegrip_pulldown_behind_the_neck", + "name": "Wide-Grip Pulldown Behind the Neck", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Wide-Grip Pulldown Behind The Neck" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "widegrip_rear_pullup", + "name": "Wide-Grip Rear Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Wide-Grip Rear Pull-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "atlas_stone_trainer", + "name": "Atlas Stone Trainer", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Other" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "atlas_stones", + "name": "Atlas Stones", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Other" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "axle_deadlift", + "name": "Axle Deadlift", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_deadlift", + "name": "Barbell Deadlift", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cat_stretch", + "name": "Cat Stretch", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "childs_pose", + "name": "Child's Pose", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "crossover_reverse_lunge", + "name": "Crossover Reverse Lunge", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dancers_stretch", + "name": "Dancer's Stretch", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "deadlift_with_bands", + "name": "Deadlift With Bands", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [ + "Deadlift with Bands" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "deadlift_with_chains", + "name": "Deadlift With Chains", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "Deadlift with Chains" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "deficit_deadlift", + "name": "Deficit Deadlift", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hug_a_ball", + "name": "Hug A Ball", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hug_knees_to_chest", + "name": "Hug Knees to Chest", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "Hug Knees To Chest" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hyperextensions_back_extensions", + "name": "Hyperextensions (back Extensions)", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Back Extension Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [ + "Back Extension", + "Hyperextension", + "Hyperextensions (Back Extensions)" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_exrx", + "source_wger" + ] + }, + { + "id": "hyperextensions_with_no_hyperextension_bench", + "name": "Hyperextensions With No Hyperextension Bench", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "keg_load", + "name": "Keg Load", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Other" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lower_backsmr", + "name": "Lower Back-SMR", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Foam Roller", + "apparatus": [ + "Foam Roller" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pelvic_tilt_into_bridge", + "name": "Pelvic Tilt Into Bridge", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pyramid", + "name": "Pyramid", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rack_pull_with_bands", + "name": "Rack Pull With Bands", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [ + "Rack Pull with Bands" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_band_deadlift", + "name": "Reverse Band Deadlift", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_pelvic_tilt", + "name": "Standing Pelvic Tilt", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stiff_leg_barbell_good_morning", + "name": "Stiff Leg Barbell Good Morning", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "superman", + "name": "Superman", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "weighted_ball_hyperextension", + "name": "Weighted Ball Hyperextension", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "alternating_kettlebell_row", + "name": "Alternating Kettlebell Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "alternating_renegade_row", + "name": "Alternating Renegade Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bent_over_barbell_row", + "name": "Bent Over Barbell Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bent_over_onearm_long_bar_row", + "name": "Bent Over One-Arm Long Bar Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bent_over_twoarm_long_bar_row", + "name": "Bent Over Two-Arm Long Bar Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bent_over_twodumbbell_row", + "name": "Bent Over Two-Dumbbell Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bent_over_twodumbbell_row_with_palms_in", + "name": "Bent Over Two-Dumbbell Row With Palms In", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bodyweight_mid_row", + "name": "Bodyweight Mid Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_incline_row", + "name": "Dumbbell Incline Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_bench_pull", + "name": "Incline Bench Pull", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "inverted_row", + "name": "Inverted Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "inverted_row_with_straps", + "name": "Inverted Row With Straps", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Inverted Row with Straps" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "leverage_high_row", + "name": "Leverage High Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_cambered_barbell_row", + "name": "Lying Cambered Barbell Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_tbar_row", + "name": "Lying T-Bar Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "middle_back_shrug", + "name": "Middle Back Shrug", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "middle_back_stretch", + "name": "Middle Back Stretch", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "mixed_grip_chin", + "name": "Mixed Grip Chin", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "one_arm_chinup", + "name": "One Arm Chin-up", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [ + "One Arm Chin-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_dumbbell_row", + "name": "One-Arm Dumbbell Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_kettlebell_row", + "name": "One-Arm Kettlebell Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_long_bar_row", + "name": "One-Arm Long Bar Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_grip_bentover_rows", + "name": "Reverse Grip Bent-Over Rows", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rhomboidssmr", + "name": "Rhomboids-SMR", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Foam Roller", + "apparatus": [ + "Foam Roller" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_cable_rows", + "name": "Seated Cable Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Cable Row", + "Seated Cable Rows" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_onearm_cable_pulley_rows", + "name": "Seated One-Arm Cable Pulley Rows", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Seated One-arm Cable Pulley Rows" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sled_row", + "name": "Sled Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_bent_over_row", + "name": "Smith Machine Bent Over Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "spinal_stretch", + "name": "Spinal Stretch", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "straight_bar_bench_mid_rows", + "name": "Straight Bar Bench Mid Rows", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "suspended_row", + "name": "Suspended Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tbar_row_with_handle", + "name": "T-Bar Row With Handle", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "T-Bar Row with Handle" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "twoarm_kettlebell_row", + "name": "Two-Arm Kettlebell Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "upper_back_stretch", + "name": "Upper Back Stretch", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chin_to_chest_stretch", + "name": "Chin to Chest Stretch", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [ + "Chin To Chest Stretch" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "isometric_neck_exercise__front_and_back", + "name": "Isometric Neck Exercise - Front and Back", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [ + "Isometric Neck Exercise - Front And Back" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "isometric_neck_exercise__sides", + "name": "Isometric Neck Exercise - Sides", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_face_down_plate_neck_resistance", + "name": "Lying Face down Plate Neck Resistance", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [ + "Lying Face Down Plate Neck Resistance" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_face_up_plate_neck_resistance", + "name": "Lying Face up Plate Neck Resistance", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [ + "Lying Face Up Plate Neck Resistance" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "necksmr", + "name": "Neck-SMR", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Foam Roller", + "apparatus": [ + "Foam Roller" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_head_harness_neck_resistance", + "name": "Seated Head Harness Neck Resistance", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Neck Harness", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_neck_stretch", + "name": "Side Neck Stretch", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "all_fours_quad_stretch", + "name": "All Fours Quad Stretch", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "alternate_leg_diagonal_bound", + "name": "Alternate Leg Diagonal Bound", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "backward_drag", + "name": "Backward Drag", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_full_squat", + "name": "Barbell Full Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_hack_squat_barbell", + "name": "Hack Squat - Barbell", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [ + "Barbell Hack Squat" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_lunge", + "name": "Barbell Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_side_split_squat", + "name": "Barbell Side Split Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_squat", + "name": "Barbell Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_squat_to_a_bench", + "name": "Barbell Squat to A Bench", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [ + "Barbell Squat To A Bench" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_step_ups", + "name": "Barbell Step Ups", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_walking_lunge", + "name": "Barbell Walking Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bear_crawl_sled_drags", + "name": "Bear Crawl Sled Drags", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bench_jump", + "name": "Bench Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Box/Platform", + "apparatus": [ + "Box" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bench_sprint", + "name": "Bench Sprint", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bicycling", + "name": "Bicycling", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bicycling_stationary", + "name": "Bicycling, Stationary", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bodyweight_squat", + "name": "Bodyweight Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bodyweight_walking_lunge", + "name": "Bodyweight Walking Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "box_squat", + "name": "Box Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "box_squat_with_bands", + "name": "Box Squat With Bands", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [ + "Box Squat with Bands" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "box_squat_with_chains", + "name": "Box Squat With Chains", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [ + "Box Squat with Chains" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_deadlifts", + "name": "Cable Deadlifts", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_hip_adduction", + "name": "Cable Hip Adduction", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Glutes", + "Quadriceps", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "car_deadlift", + "name": "Car Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chair_squat", + "name": "Chair Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Box/Platform", + "apparatus": [ + "Box" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "clean_pull", + "name": "Clean Pull", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals", + "Traps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "clean_from_blocks", + "name": "Clean From Blocks", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals", + "Traps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Clean from Blocks" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "conans_wheel", + "name": "Conan's Wheel", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Other" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "depth_jump_leap", + "name": "Depth Jump Leap", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "double_leg_butt_kick", + "name": "Double Leg Butt Kick", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_lunges", + "name": "Dumbbell Lunges", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_rear_lunge", + "name": "Dumbbell Rear Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_seated_box_jump", + "name": "Dumbbell Seated Box Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "duration", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_squat", + "name": "Dumbbell Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_squat_to_a_bench", + "name": "Dumbbell Squat to A Bench", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [ + "Dumbbell Squat To A Bench" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_step_ups", + "name": "Dumbbell Step-Up", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Dumbbell Step Ups", + "Dumbbell Step-up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "elevated_back_lunge", + "name": "Elevated Back Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "elliptical_trainer", + "name": "Elliptical Trainer", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "fast_skipping", + "name": "Fast Skipping", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "frankenstein_squat", + "name": "Frankenstein Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "freehand_jump_squat", + "name": "Freehand Jump Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "frog_hops", + "name": "Frog Hops", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_barbell_squat", + "name": "Front Barbell Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_barbell_squat_to_a_bench", + "name": "Front Barbell Squat to A Bench", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [ + "Front Barbell Squat To A Bench" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_cone_hops_or_hurdle_hops", + "name": "Front Cone Hops (or Hurdle Hops)", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [ + "Front Cone Hops (or hurdle hops)" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_squat_clean_grip", + "name": "Front Squat (clean Grip)", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Front Squat (Clean Grip)" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_squats_with_two_kettlebells", + "name": "Front Squats With Two Kettlebells", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "goblet_squat", + "name": "Goblet Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hack_squat", + "name": "Hack Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Hack Squat Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hang_clean", + "name": "Hang Clean", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals", + "Traps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hang_clean__below_the_knees", + "name": "Hang Clean - Below the Knees", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals", + "Traps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "heaving_snatch_balance", + "name": "Heaving Snatch Balance", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals", + "Traps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hip_flexion_with_band", + "name": "Hip Flexion With Band", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Hip Flexion with Band" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "intermediate_hip_flexor_and_quad_stretch", + "name": "Intermediate Hip Flexor and Quad Stretch", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "iron_crosses_stretch", + "name": "Iron Crosses (stretch)", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "jefferson_squats", + "name": "Jefferson Squats", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "jerk_dip_squat", + "name": "Jerk Dip Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "jogging_treadmill", + "name": "Jogging, Treadmill", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_pistol_squat", + "name": "Kettlebell Pistol Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kneeling_hip_flexor", + "name": "Kneeling Hip Flexor", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "leg_extensions", + "name": "Leg Extension", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Leg Extensions" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "leg_press", + "name": "Leg Press", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Leg Press Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "leverage_deadlift", + "name": "Leverage Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "linear_depth_jump", + "name": "Linear Depth Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "looking_at_ceiling", + "name": "Looking At Ceiling", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lunge_sprint", + "name": "Lunge Sprint", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_machine_squat", + "name": "Lying Machine Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_prone_quadriceps", + "name": "Lying Prone Quadriceps", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "narrow_stance_hack_squats", + "name": "Narrow Stance Hack Squats", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Hack Squat Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "narrow_stance_leg_press", + "name": "Narrow Stance Leg Press", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Leg Press Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "narrow_stance_squats", + "name": "Narrow Stance Squats", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "olympic_squat", + "name": "Olympic Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "on_your_side_quad_stretch", + "name": "on Your Side Quad Stretch", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "On Your Side Quad Stretch" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onyourback_quad_stretch", + "name": "on-Your-Back Quad Stretch", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "On-Your-Back Quad Stretch" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "one_half_locust", + "name": "One Half Locust", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "one_leg_barbell_squat", + "name": "One Leg Barbell Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_overhead_kettlebell_squats", + "name": "One-Arm Overhead Kettlebell Squats", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_side_deadlift", + "name": "One-Arm Side Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "overhead_squat", + "name": "Overhead Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plie_dumbbell_squat", + "name": "Plie Dumbbell Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "power_jerk", + "name": "Power Jerk", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals", + "Traps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "power_snatch_from_blocks", + "name": "Power Snatch From Blocks", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals", + "Traps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [ + "Power Snatch from Blocks" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "quad_stretch", + "name": "Quad Stretch", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "quadricepssmr", + "name": "Quadriceps-SMR", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Foam Roller", + "apparatus": [ + "Foam Roller" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "quick_leap", + "name": "Quick Leap", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rear_leg_raises", + "name": "Rear Leg Raises", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "recumbent_bike", + "name": "Recumbent Bike", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_band_box_squat", + "name": "Reverse Band Box Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_band_power_squat", + "name": "Reverse Band Power Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rickshaw_deadlift", + "name": "Rickshaw Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rocket_jump", + "name": "Rocket Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rope_jumping", + "name": "Rope Jumping", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Rope", + "apparatus": [ + "Rope" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rowing_stationary", + "name": "Rowing, Stationary", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "running_treadmill", + "name": "Running, Treadmill", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sandbag_load", + "name": "Sandbag Load", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Other", + "equipmentDetail": "Sandbag", + "apparatus": [ + "Sandbag" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "scissors_jump", + "name": "Scissors Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_hopsprint", + "name": "Side Hop-Sprint", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_standing_long_jump", + "name": "Side Standing Long Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_to_side_box_shuffle", + "name": "Side to Side Box Shuffle", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Box/Platform", + "apparatus": [ + "Box" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_butt_kick", + "name": "Single Leg Butt Kick", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_pushoff", + "name": "Single Leg Push-Off", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Single Leg Push-off" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "singlecone_sprint_drill", + "name": "Single-Cone Sprint Drill", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "singleleg_high_box_squat", + "name": "Single-Leg High Box Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "singleleg_hop_progression", + "name": "Single-Leg Hop Progression", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "singleleg_lateral_hop", + "name": "Single-Leg Lateral Hop", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "singleleg_leg_extension", + "name": "Single-Leg Leg Extension", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "singleleg_stride_jump", + "name": "Single-Leg Stride Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sit_squats", + "name": "Sit Squats", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "skating", + "name": "Skating", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sled_drag__harness", + "name": "Sled Drag - Harness", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sled_push", + "name": "Sled Push", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_leg_press", + "name": "Smith Machine Leg Press", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_pistol_squat", + "name": "Smith Machine Pistol Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_squat_smith_machine", + "name": "Squat - Smith Machine", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_singleleg_split_squat", + "name": "Smith Single-Leg Split Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "snatch", + "name": "Snatch", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals", + "Traps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "snatch_balance", + "name": "Snatch Balance", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals", + "Traps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "snatch_from_blocks", + "name": "Snatch From Blocks", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals", + "Traps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [ + "Snatch from Blocks" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "speed_box_squat", + "name": "Speed Box Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "speed_squats", + "name": "Speed Squats", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "split_clean", + "name": "Split Clean", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals", + "Traps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "split_jerk", + "name": "Split Jerk", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals", + "Traps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "split_jump", + "name": "Split Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "split_squat_with_dumbbells", + "name": "Split Squat With Dumbbells", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [ + "Split Squat with Dumbbells" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "squat_jerk", + "name": "Squat Jerk", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "squat_with_bands", + "name": "Squat With Bands", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [ + "Squat with Bands" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "squat_with_chains", + "name": "Squat With Chains", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [ + "Squat with Chains" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "squat_with_plate_movers", + "name": "Squat With Plate Movers", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [ + "Squat with Plate Movers" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "squats__with_bands", + "name": "Squats - With Bands", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stairmaster", + "name": "Stairmaster", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_elevated_quad_stretch", + "name": "Standing Elevated Quad Stretch", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_hip_flexors", + "name": "Standing Hip Flexors", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_long_jump", + "name": "Standing Long Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "star_jump", + "name": "Star Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "step_mill", + "name": "Step Mill", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stride_jump_crossover", + "name": "Stride Jump Crossover", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "suspended_split_squat", + "name": "Suspended Split Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tire_flip", + "name": "Tire Flip", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Other" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trail_runningwalking", + "name": "Trail Running/Walking", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "trap_bar_deadlift", + "name": "Trap Bar Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Trap Bar", + "apparatus": [ + "Trap Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "walking_treadmill", + "name": "Walking, Treadmill", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "weighted_jump_squat", + "name": "Weighted Jump Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "weighted_sissy_squat", + "name": "Weighted Sissy Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "weighted_squat", + "name": "Weighted Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wide_stance_barbell_squat", + "name": "Wide Stance Barbell Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "yoke_walk", + "name": "Yoke Walk", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Other" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "alternating_cable_shoulder_press", + "name": "Alternating Cable Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "alternating_deltoid_raise", + "name": "Alternating Deltoid Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "alternating_kettlebell_press", + "name": "Alternating Kettlebell Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "antigravity_press", + "name": "Anti-Gravity Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "arm_circles", + "name": "Arm Circles", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "arnold_dumbbell_press", + "name": "Arnold Dumbbell Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "back_flyes__with_bands", + "name": "Back Flyes - With Bands", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "backward_medicine_ball_throw", + "name": "Backward Medicine Ball Throw", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "band_pull_apart", + "name": "Band Pull Apart", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Pull-Apart - Band" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_incline_shoulder_raise", + "name": "Barbell Incline Shoulder Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_rear_delt_row_barbell", + "name": "Rear Delt Row - Barbell", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Barbell Rear Delt Row" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_shoulder_press", + "name": "Barbell Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "battling_ropes", + "name": "Battling Ropes", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Traps", + "Shoulders", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Battle Rope", + "apparatus": [ + "Battle Rope" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bent_over_dumbbell_rear_delt_raise_with_head_on_bench", + "name": "Bent Over Dumbbell Rear Delt Raise With Head on Bench", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Bent Over Dumbbell Rear Delt Raise With Head On Bench" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bent_over_lowpulley_side_lateral", + "name": "Bent Over Low-Pulley Side Lateral", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bradfordrocky_presses", + "name": "Bradford/Rocky Presses", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Traps", + "Core" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [ + "Bradford Press", + "Rocky Press" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_internal_rotation", + "name": "Cable Internal Rotation", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_rear_delt_fly", + "name": "Cable Rear Delt Fly", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_rope_reardelt_rows", + "name": "Cable Rope Rear-Delt Rows", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_seated_lateral_raise", + "name": "Cable Seated Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_shoulder_press", + "name": "Cable Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "car_drivers", + "name": "Car Drivers", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chair_upper_body_stretch", + "name": "Chair Upper Body Stretch", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Box/Platform", + "apparatus": [ + "Box" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "circus_bell", + "name": "Circus Bell", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "clean_and_jerk", + "name": "Clean and Jerk", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals", + "Glutes", + "Hamstrings", + "Quadriceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "clean_and_press", + "name": "Clean and Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "crucifix", + "name": "Crucifix", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cuban_press", + "name": "Cuban Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "double_kettlebell_jerk", + "name": "Double Kettlebell Jerk", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals", + "Glutes", + "Hamstrings", + "Quadriceps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "double_kettlebell_push_press", + "name": "Double Kettlebell Push Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "double_kettlebell_snatch", + "name": "Double Kettlebell Snatch", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals", + "Glutes", + "Hamstrings", + "Quadriceps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_incline_shoulder_raise", + "name": "Dumbbell Incline Shoulder Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_lying_onearm_rear_lateral_raise", + "name": "Dumbbell Lying One-Arm Rear Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_lying_rear_lateral_raise", + "name": "Dumbbell Lying Rear Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_onearm_shoulder_press", + "name": "Dumbbell One-Arm Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_onearm_upright_row", + "name": "Dumbbell One-Arm Upright Row", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_raise", + "name": "Dumbbell Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_scaption", + "name": "Dumbbell Scaption", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_shoulder_press", + "name": "Dumbbell Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "elbow_circles", + "name": "Elbow Circles", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "external_rotation", + "name": "External Rotation", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "external_rotation_with_band", + "name": "External Rotation With Band", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [ + "External Rotation with Band" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "external_rotation_with_cable", + "name": "External Rotation With Cable", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [ + "External Rotation with Cable" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "face_pull", + "name": "Face Pull", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_cable_raise", + "name": "Front Cable Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_dumbbell_raise", + "name": "Front Dumbbell Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_incline_dumbbell_raise", + "name": "Front Incline Dumbbell Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_plate_raise", + "name": "Front Plate Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_twodumbbell_raise", + "name": "Front Two-Dumbbell Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "handstand_pushups", + "name": "Handstand Push-Ups", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [ + "HSPU", + "Handstand Push-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "duplicate_candidate", + "source_exrx" + ] + }, + { + "id": "internal_rotation_with_band", + "name": "Internal Rotation With Band", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [ + "Internal Rotation with Band" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "iron_cross", + "name": "Iron Cross", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "jerk_balance", + "name": "Jerk Balance", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals", + "Glutes", + "Hamstrings", + "Quadriceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_arnold_press", + "name": "Kettlebell Arnold Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_pirate_ships", + "name": "Kettlebell Pirate Ships", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_seated_press", + "name": "Kettlebell Seated Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_seesaw_press", + "name": "Kettlebell Seesaw Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_thruster", + "name": "Kettlebell Thruster", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_turkish_getup_lunge_style", + "name": "Kettlebell Turkish Get-up (lunge Style)", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Abdominals", + "Shoulders", + "Glutes", + "Quadriceps", + "Hamstrings", + "Traps", + "Lats" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "advanced", + "aliases": [ + "Kettlebell Turkish Get-Up (Lunge style)" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_strengthlog", + "source_wger" + ] + }, + { + "id": "kettlebell_turkish_getup_squat_style", + "name": "Kettlebell Turkish Get-up (squat Style)", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Abdominals", + "Shoulders", + "Glutes", + "Quadriceps", + "Hamstrings", + "Traps", + "Lats" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "advanced", + "aliases": [ + "Kettlebell Turkish Get-Up (Squat style)" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_strengthlog", + "source_wger" + ] + }, + { + "id": "kneeling_arm_drill", + "name": "Kneeling Arm Drill", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "landmine_linear_jammer", + "name": "Landmine Linear Jammer", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lateral_raise__with_bands", + "name": "Lateral Raise - With Bands", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "leverage_shoulder_press", + "name": "Leverage Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "log_lift", + "name": "Log Lift", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Other" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "low_pulley_row_to_neck", + "name": "Low Pulley Row to Neck", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Low Pulley Row To Neck" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_onearm_lateral_raise", + "name": "Lying One-Arm Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_rear_delt_raise", + "name": "Lying Rear Delt Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_shoulder_military_press", + "name": "Machine Shoulder (military) Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Machine Shoulder (Military) Press" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "medicine_ball_scoop_throw", + "name": "Medicine Ball Scoop Throw", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "onearm_incline_lateral_raise", + "name": "One-Arm Incline Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_kettlebell_clean_and_jerk", + "name": "One-Arm Kettlebell Clean and Jerk", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals", + "Glutes", + "Hamstrings", + "Quadriceps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_kettlebell_jerk", + "name": "One-Arm Kettlebell Jerk", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals", + "Glutes", + "Hamstrings", + "Quadriceps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_kettlebell_military_press_to_the_side", + "name": "One-Arm Kettlebell Military Press to the Side", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "One-Arm Kettlebell Military Press To The Side" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_kettlebell_para_press", + "name": "One-Arm Kettlebell Para Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_kettlebell_push_press", + "name": "One-Arm Kettlebell Push Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_kettlebell_snatch", + "name": "One-Arm Kettlebell Snatch", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals", + "Glutes", + "Hamstrings", + "Quadriceps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_kettlebell_split_jerk", + "name": "One-Arm Kettlebell Split Jerk", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals", + "Glutes", + "Hamstrings", + "Quadriceps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_kettlebell_split_snatch", + "name": "One-Arm Kettlebell Split Snatch", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals", + "Glutes", + "Hamstrings", + "Quadriceps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "onearm_side_laterals", + "name": "One-Arm Side Laterals", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "power_partials", + "name": "Power Partials", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "push_press", + "name": "Push Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "push_press__behind_the_neck", + "name": "Push Press - Behind the Neck", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rack_delivery", + "name": "Rack Delivery", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Other" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "return_push_from_stance", + "name": "Return Push From Stance", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Return Push from Stance" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_flyes", + "name": "Reverse Flyes", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_flyes_with_external_rotation", + "name": "Reverse Flyes With External Rotation", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_machine_flyes", + "name": "Reverse Machine Flyes", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "round_the_world_shoulder_stretch", + "name": "Round the World Shoulder Stretch", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Round The World Shoulder Stretch" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_barbell_military_press", + "name": "Seated Barbell Military Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_bentover_rear_delt_raise", + "name": "Seated Bent-Over Rear Delt Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_cable_shoulder_press", + "name": "Seated Cable Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_dumbbell_press", + "name": "Seated Dumbbell Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_front_deltoid", + "name": "Seated Front Deltoid", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_side_lateral_raise", + "name": "Seated Side Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seesaw_press_alternating_side_press", + "name": "See-Saw Press (alternating Side Press)", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "See-Saw Press (Alternating Side Press)" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "shoulder_circles", + "name": "Shoulder Circles", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "shoulder_press__with_bands", + "name": "Shoulder Press - With Bands", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "shoulder_raise", + "name": "Shoulder Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "shoulder_stretch", + "name": "Shoulder Stretch", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_lateral_raise", + "name": "Side Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_laterals_to_front_raise", + "name": "Side Laterals to Front Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_wrist_pull", + "name": "Side Wrist Pull", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_dumbbell_raise", + "name": "Single Dumbbell Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "singlearm_linear_jammer", + "name": "Single-Arm Linear Jammer", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sled_overhead_backward_walk", + "name": "Sled Overhead Backward Walk", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sled_reverse_flye", + "name": "Sled Reverse Flye", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_incline_shoulder_raise", + "name": "Smith Incline Shoulder Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_onearm_upright_row", + "name": "Smith Machine One-Arm Upright Row", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_overhead_shoulder_press", + "name": "Smith Machine Overhead Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_alternating_dumbbell_press", + "name": "Standing Alternating Dumbbell Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_barbell_press_behind_neck", + "name": "Standing Barbell Press Behind Neck", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_bradford_press", + "name": "Standing Bradford Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_dumbbell_press", + "name": "Standing Dumbbell Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_dumbbell_straightarm_front_delt_raise_above_head", + "name": "Standing Dumbbell Straight-Arm Front Delt Raise Above Head", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_front_barbell_raise_over_head", + "name": "Standing Front Barbell Raise Over Head", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_lowpulley_deltoid_raise", + "name": "Standing Low-Pulley Deltoid Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_military_press", + "name": "Standing Military Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_palmin_onearm_dumbbell_press", + "name": "Standing Palm-In One-Arm Dumbbell Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_palmsin_dumbbell_press", + "name": "Standing Palms-In Dumbbell Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_twoarm_overhead_throw", + "name": "Standing Two-Arm Overhead Throw", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "straight_raises_on_incline_bench", + "name": "Straight Raises on Incline Bench", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "twoarm_kettlebell_clean", + "name": "Two-Arm Kettlebell Clean", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals", + "Glutes", + "Hamstrings", + "Quadriceps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "twoarm_kettlebell_jerk", + "name": "Two-Arm Kettlebell Jerk", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals", + "Glutes", + "Hamstrings", + "Quadriceps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "twoarm_kettlebell_military_press", + "name": "Two-Arm Kettlebell Military Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "upright_barbell_row", + "name": "Upright Barbell Row", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "upward_stretch", + "name": "Upward Stretch", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_shrug", + "name": "Barbell Shrug", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back", + "Shoulders", + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_shrug_behind_the_back", + "name": "Barbell Shrug Behind the Back", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back", + "Shoulders", + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [ + "Barbell Shrug Behind The Back" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "calfmachine_shoulder_shrug", + "name": "Calf-Machine Shoulder Shrug", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back", + "Shoulders", + "Forearms" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "clean_shrug", + "name": "Clean Shrug", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back", + "Shoulders", + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_shrug", + "name": "Dumbbell Shrug", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back", + "Shoulders", + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_sumo_high_pull", + "name": "Kettlebell Sumo High Pull", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back", + "Shoulders", + "Forearms" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "leverage_shrug", + "name": "Leverage Shrug", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back", + "Shoulders", + "Forearms" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "scapular_pullup", + "name": "Scapular Pull-up", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back", + "Shoulders", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Scapular Pull-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_behind_the_back_shrug", + "name": "Smith Machine Behind the Back Shrug", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back", + "Shoulders", + "Forearms" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_upright_row", + "name": "Smith Machine Upright Row", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back", + "Shoulders", + "Forearms" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "snatch_shrug", + "name": "Snatch Shrug", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back", + "Shoulders", + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_dumbbell_upright_row", + "name": "Standing Dumbbell Upright Row", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back", + "Shoulders", + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "upright_cable_row", + "name": "Upright Cable Row", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back", + "Shoulders", + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "upright_row__with_bands", + "name": "Upright Row - With Bands", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back", + "Shoulders", + "Forearms" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_skull_crusher", + "name": "Band Skull Crusher", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bench_dips", + "name": "Bench Dips", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Box/Platform", + "apparatus": [ + "Box" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bench_press__powerlifting", + "name": "Bench Press - Powerlifting", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bench_press_with_chains", + "name": "Bench Press With Chains", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Bench Press with Chains" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "board_press", + "name": "Board Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "body_tricep_press", + "name": "Body Tricep Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bodyup", + "name": "Body-up", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Body-Up" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_incline_triceps_extension", + "name": "Cable Incline Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_lying_triceps_extension", + "name": "Cable Lying Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_one_arm_tricep_extension", + "name": "Cable One Arm Tricep Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_rope_overhead_triceps_extension", + "name": "Cable Rope Overhead Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chain_handle_extension", + "name": "Chain Handle Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "closegrip_barbell_bench_press", + "name": "Close-Grip Barbell Bench Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "closegrip_dumbbell_press", + "name": "Close-Grip Dumbbell Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "closegrip_ezbar_press", + "name": "Close-Grip EZ-Bar Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "EZ Bar", + "apparatus": [ + "EZ Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "closegrip_pushup_off_of_a_dumbbell", + "name": "Close-Grip Push-up Off of A Dumbbell", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Close-Grip Push-Up off of a Dumbbell" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_closegrip_bench_to_skull_crusher", + "name": "Decline Close-Grip Bench to Skull Crusher", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Decline Close-Grip Bench To Skull Crusher" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_dumbbell_triceps_extension", + "name": "Decline Dumbbell Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_ez_bar_triceps_extension", + "name": "Decline EZ Bar Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "EZ Bar", + "apparatus": [ + "EZ Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dip_machine", + "name": "Dip Machine", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dips__triceps_version", + "name": "Dips - Triceps Version", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Dip Bars", + "apparatus": [ + "Dip Bars" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_floor_press", + "name": "Dumbbell Floor Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_onearm_triceps_extension", + "name": "Dumbbell One-Arm Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_tricep_extension_pronated_grip", + "name": "Dumbbell Tricep Extension -Pronated Grip", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ezbar_skullcrusher", + "name": "EZ-Bar Skullcrusher", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "EZ Bar", + "apparatus": [ + "EZ Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "floor_press", + "name": "Floor Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "floor_press_with_chains", + "name": "Floor Press With Chains", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Floor Press with Chains" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_barbell_triceps_extension", + "name": "Incline Barbell Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_pushup_closegrip", + "name": "Incline Push-up Close-Grip", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Incline Push-Up Close-Grip" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "jm_press", + "name": "JM Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kneeling_cable_triceps_extension", + "name": "Kneeling Cable Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "low_cable_triceps_extension", + "name": "Low Cable Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_closegrip_barbell_triceps_extension_behind_the_head", + "name": "Lying Close-Grip Barbell Triceps Extension Behind the Head", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Lying Close-Grip Barbell Triceps Extension Behind The Head" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_closegrip_barbell_triceps_press_to_chin", + "name": "Lying Close-Grip Barbell Triceps Press to Chin", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Lying Close-Grip Barbell Triceps Press To Chin" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_dumbbell_tricep_extension", + "name": "Lying Dumbbell Tricep Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_triceps_press", + "name": "Lying Triceps Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_triceps_extension", + "name": "Machine Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "one_arm_floor_press", + "name": "One Arm Floor Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "one_arm_pronated_dumbbell_triceps_extension", + "name": "One Arm Pronated Dumbbell Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "one_arm_supinated_dumbbell_triceps_extension", + "name": "One Arm Supinated Dumbbell Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "overhead_triceps", + "name": "Overhead Triceps", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "parallel_bar_dip", + "name": "Parallel Bar Dip", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Dip Bars", + "apparatus": [ + "Dip Bars" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pin_presses", + "name": "Pin Presses", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pushups__close_triceps_position", + "name": "Push-Ups - Close Triceps Position", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Press-Up", + "Pushup" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_exrx", + "source_wger" + ] + }, + { + "id": "reverse_band_bench_press", + "name": "Reverse Band Bench Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_grip_triceps_pushdown", + "name": "Reverse Grip Triceps Pushdown", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_triceps_bench_press", + "name": "Reverse Triceps Bench Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ring_dips", + "name": "Ring Dips", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Rings", + "apparatus": [ + "Rings" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_bentover_onearm_dumbbell_triceps_extension", + "name": "Seated Bent-Over One-Arm Dumbbell Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_bentover_twoarm_dumbbell_triceps_extension", + "name": "Seated Bent-Over Two-Arm Dumbbell Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_triceps_press", + "name": "Seated Triceps Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sled_overhead_triceps_extension", + "name": "Sled Overhead Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_closegrip_bench_press", + "name": "Smith Machine Close-Grip Bench Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "speed_band_overhead_triceps", + "name": "Speed Band Overhead Triceps", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_bentover_onearm_dumbbell_triceps_extension", + "name": "Standing Bent-Over One-Arm Dumbbell Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_bentover_twoarm_dumbbell_triceps_extension", + "name": "Standing Bent-Over Two-Arm Dumbbell Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_dumbbell_triceps_extension", + "name": "Standing Dumbbell Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_lowpulley_onearm_triceps_extension", + "name": "Standing Low-Pulley One-Arm Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_onearm_dumbbell_triceps_extension", + "name": "Standing One-Arm Dumbbell Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_overhead_barbell_triceps_extension", + "name": "Standing Overhead Barbell Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_towel_triceps_extension", + "name": "Standing Towel Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Core" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Towel" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "supine_chest_throw", + "name": "Supine Chest Throw", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "tate_press", + "name": "Tate Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tricep_dumbbell_kickback", + "name": "Tricep Dumbbell Kickback", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tricep_side_stretch", + "name": "Tricep Side Stretch", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "triceps_overhead_extension_with_rope", + "name": "Triceps Overhead Extension With Rope", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Triceps Overhead Extension with Rope" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "triceps_pushdown", + "name": "Triceps Pushdown", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "triceps_pushdown__rope_attachment", + "name": "Triceps Pushdown - Rope Attachment", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "triceps_pushdown__vbar_attachment", + "name": "Triceps Pushdown - V-Bar Attachment", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "triceps_stretch", + "name": "Triceps Stretch", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "weighted_bench_dip", + "name": "Weighted Bench Dip", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Box/Platform", + "apparatus": [ + "Box" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_calf_raise_leg_press_machine", + "name": "Calf Raise - Leg Press Machine", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Leg Press Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_close_grip_feet_up_bench_press_barbell", + "name": "Close-Grip Feet-up Bench Press - Barbell", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Close-Grip Feet-Up Bench Press - Barbell" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_deadlift_trap_bar_high_handles", + "name": "Deadlift - Trap Bar High Handles", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Trap Bar", + "apparatus": [ + "Trap Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_deadlift_trap_bar_low_handles", + "name": "Deadlift - Trap Bar Low Handles", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Trap Bar", + "apparatus": [ + "Trap Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_external_shoulder_rotation_band", + "name": "External Shoulder Rotation - Band", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_external_shoulder_rotation_cable", + "name": "External Shoulder Rotation - Cable", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_glute_kickback_machine", + "name": "Glute Kickback - Machine", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_hack_squat_landmine", + "name": "Hack Squat - Landmine", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Hack Squat Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_hack_squat_machine", + "name": "Hack Squat - Machine", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Hack Squat Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_high_to_low_wood_chop_cable", + "name": "High to Low Wood Chop - Cable", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Obliques" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_hip_abduction_band", + "name": "Hip Abduction - Band", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_hip_abduction_machine", + "name": "Hip Abduction - Machine", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_hip_adduction_machine", + "name": "Hip Adduction - Machine", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Glutes", + "Quadriceps", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_horizontal_external_shoulder_rotation_dumbbell", + "name": "Horizontal External Shoulder Rotation - Dumbbell", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_horizontal_internal_shoulder_rotation_dumbbell", + "name": "Horizontal Internal Shoulder Rotation - Dumbbell", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_horizontal_wood_chop_band", + "name": "Horizontal Wood Chop - Band", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Obliques" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_horizontal_wood_chop_cable", + "name": "Horizontal Wood Chop - Cable", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Obliques" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_internal_shoulder_rotation_band", + "name": "Internal Shoulder Rotation - Band", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_internal_shoulder_rotation_cable", + "name": "Internal Shoulder Rotation - Cable", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_landmine_rotation", + "name": "Landmine Rotation", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Obliques" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_leg_press_machine", + "name": "Leg Press - Machine", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Leg Press Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_lying_external_shoulder_rotation_dumbbell", + "name": "Lying External Shoulder Rotation - Dumbbell", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_lying_internal_shoulder_rotation_dumbbell", + "name": "Lying Internal Shoulder Rotation - Dumbbell", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_one_handed_shoulder_press_landmine", + "name": "One-Handed Shoulder Press - Landmine", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_pause_squat_barbell", + "name": "Pause Squat - Barbell", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_pullover_barbell", + "name": "Pullover - Barbell", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_pullover_dumbbell", + "name": "Pullover - Dumbbell", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_rear_delt_row_cable", + "name": "Rear Delt Row - Cable", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Lats", + "Biceps", + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_rear_delt_row_dumbbell", + "name": "Rear Delt Row - Dumbbell", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Lats", + "Biceps", + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_row_t_bar", + "name": "Row - T-Bar", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_seated_shoulder_press_smith_machine", + "name": "Seated Shoulder Press - Smith Machine", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_shrug_trap_bar", + "name": "Shrug - Trap Bar", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back", + "Shoulders", + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Trap Bar", + "apparatus": [ + "Trap Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_single_arm_half_kneeling_high_row_cable", + "name": "Single Arm Half Kneeling High Row - Cable", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_single_leg_romanian_deadlift_landmine", + "name": "Single-Leg Romanian Deadlift - Landmine", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "RDL" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_squat_belt", + "name": "Squat - Belt", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_squat_landmine", + "name": "Squat - Landmine", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_stability_ball_pullover_dumbbell", + "name": "Stability Ball Pullover - Dumbbell", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_standing_external_shoulder_rotation_dumbbell", + "name": "Standing External Shoulder Rotation - Dumbbell", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_standing_glute_kickback_machine", + "name": "Standing Glute Kickback - Machine", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Quadriceps", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_fly", + "name": "Cable Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_fly_low_to_high", + "name": "Cable Fly Low to High", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_fly_high_to_low", + "name": "Cable Fly High to Low", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_chest_press", + "name": "Machine Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_incline_chest_press", + "name": "Machine Incline Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chest_supported_row", + "name": "Chest-Supported Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chest_supported_dumbbell_row", + "name": "Chest-Supported Dumbbell Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seal_row", + "name": "Seal Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pendlay_row", + "name": "Pendlay Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "meadows_row", + "name": "Meadows Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "assisted_pull_up_machine", + "name": "Assisted Pull-up Machine", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_lateral_raise_single_arm", + "name": "Cable Lateral Raise (single Arm)", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_lateral_raise", + "name": "Machine Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_pec_deck", + "name": "Reverse Pec Deck", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bulgarian_split_squat", + "name": "Bulgarian Split Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [ + "RFESS", + "Rear-Foot-Elevated Split Squat" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "belt_squat", + "name": "Belt Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "safety_bar_squat", + "name": "Safety Bar Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "nordic_hamstring_curl", + "name": "Nordic Hamstring Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Rings", + "apparatus": [ + "Rings" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tibialis_raise", + "name": "Tibialis Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tibialis_raise_machine", + "name": "Tibialis Raise (machine)", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ski_erg", + "name": "Ski Erg", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Abdominals", + "Quadriceps", + "Glutes" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "clamshell", + "name": "Clamshell", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "fire_hydrant", + "name": "Fire Hydrant", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lateral_skater_jump", + "name": "Lateral Skater Jump", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes", + "Quadriceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_lying_hip_abduction", + "name": "Side-Lying Hip Abduction", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_cable_hip_abduction", + "name": "Standing Cable Hip Abduction", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "banded_clamshell", + "name": "Banded Clamshell", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "banded_hip_abduction", + "name": "Banded Hip Abduction", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "banded_lateral_walk", + "name": "Banded Lateral Walk", + "primaryMuscle": "Abductors", + "primaryMuscles": [ + "Abductors" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ape_walk", + "name": "Ape Walk", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "carioca", + "name": "Carioca", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Abductors", + "Abdominals", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "copenhagen_plank", + "name": "Copenhagen Plank", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Abdominals", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "copenhagen_side_plank_raise", + "name": "Copenhagen Side Plank Raise", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cossack_squat", + "name": "Cossack Squat", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes", + "Hamstrings" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "deep_squat_pry", + "name": "Deep Squat Pry", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "grapevine_step", + "name": "Grapevine Step", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Abductors", + "Abdominals", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_lunge", + "name": "Side Lunge", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes", + "Hamstrings" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_lying_hip_adduction", + "name": "Side-Lying Hip Adduction", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_lateral_lunge", + "name": "Cable Lateral Lunge", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_cable_hip_adduction", + "name": "Standing Cable Hip Adduction", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_cossack_squat", + "name": "Dumbbell Cossack Squat", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "landmine_cossack_squat", + "name": "Landmine Cossack Squat", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lateral_sled_drag", + "name": "Lateral Sled Drag", + "primaryMuscle": "Adductors", + "primaryMuscles": [ + "Adductors" + ], + "secondaryMuscles": [ + "Abductors", + "Glutes" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "21s_curl", + "name": "21s Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_preacher_curl", + "name": "Barbell Preacher Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cheat_curl", + "name": "Cheat Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_curl", + "name": "Reverse Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "scott_curl", + "name": "Scott Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bodyweight_biceps_curl", + "name": "Bodyweight Biceps Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Bar Curl" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ring_biceps_curl", + "name": "Ring Biceps Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bayesian_cable_curl", + "name": "Bayesian Cable Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "behind_the_back_cable_curl", + "name": "Behind-the-Back Cable Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_crossover_biceps_curl", + "name": "Cable Crossover Biceps Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_curl", + "name": "Cable Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_curl_with_bar", + "name": "Cable Curl With Bar", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_curl_with_rope", + "name": "Cable Curl With Rope", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_hammer_curl", + "name": "Cable Hammer Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ez_bar_cable_curl", + "name": "EZ-Bar Cable Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "face_away_bayesian_cable_curl", + "name": "Face-Away Bayesian Cable Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "low_cable_curl", + "name": "Low Cable Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_cable_curl_on_bench", + "name": "Lying Cable Curl on Bench", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_cable_curl_on_floor", + "name": "Lying Cable Curl on Floor", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rope_hammer_curl", + "name": "Rope Hammer Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_arm_cable_curl", + "name": "Single-Arm Cable Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "straight_bar_cable_curl", + "name": "Straight-Bar Cable Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "alternating_dumbbell_curl", + "name": "Alternating Dumbbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bayesian_dumbbell_curl", + "name": "Bayesian Dumbbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "concentration_curls", + "name": "Concentration Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Concentration Curls" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_curl", + "name": "Dumbbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_preacher_curl", + "name": "Dumbbell Preacher Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_spider_curl", + "name": "Dumbbell Spider Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_hammer_curls", + "name": "Incline Hammer Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Incline Hammer Curls" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "preacher_hammer_curl", + "name": "Preacher Hammer Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "prone_incline_dumbbell_curl", + "name": "Prone Incline Dumbbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_dumbbell_curl", + "name": "Reverse Dumbbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_alternating_dumbbell_curl", + "name": "Seated Alternating Dumbbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_hammer_curl", + "name": "Seated Hammer Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_arm_dumbbell_preacher_curl", + "name": "Single-Arm Dumbbell Preacher Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_dumbbell_curl", + "name": "Standing Dumbbell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "waiter_curl", + "name": "Waiter Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ez_bar_preacher_curl", + "name": "EZ-Bar Preacher Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "EZ Bar", + "apparatus": [ + "EZ Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_ez_bar_curl", + "name": "Reverse EZ-Bar Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "EZ Bar", + "apparatus": [ + "EZ Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_ez_bar_curl", + "name": "Standing EZ-Bar Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "EZ Bar", + "apparatus": [ + "EZ Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wide_grip_ez_bar_curl", + "name": "Wide-Grip EZ-Bar Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "EZ Bar", + "apparatus": [ + "EZ Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_curl", + "name": "Kettlebell Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_hammer_curl", + "name": "Kettlebell Hammer Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_curl", + "name": "Machine Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_preacher_curls", + "name": "Machine Preacher Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Machine Preacher Curls" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "preacher_curl_machine", + "name": "Preacher Curl Machine", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_curl", + "name": "Band Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_hammer_curl", + "name": "Band Hammer Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_preacher_curl", + "name": "Band Preacher Curl", + "primaryMuscle": "Biceps", + "primaryMuscles": [ + "Biceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_calf_raise", + "name": "Barbell Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ankle_rocker", + "name": "Ankle Rocker", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "calf_raise_hold", + "name": "Calf Raise Hold", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "heel_walk", + "name": "Heel Walk", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [ + "Glutes", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pogo_jump", + "name": "Pogo Jump", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [ + "Quadriceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_bodyweight_calf_raise", + "name": "Seated Bodyweight Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_calf_raise", + "name": "Single-Leg Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_pogo_jump", + "name": "Single-Leg Pogo Jump", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [ + "Quadriceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_standing_calf_raise", + "name": "Single-Leg Standing Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_tibialis_raise", + "name": "Single-Leg Tibialis Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "toe_raise", + "name": "Toe Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "toe_walk", + "name": "Toe Walk", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [ + "Glutes", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wall_tibialis_raise", + "name": "Wall Tibialis Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Wall" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_calf_raise", + "name": "Dumbbell Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_dumbbell_calf_raise", + "name": "Seated Dumbbell Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_dumbbell_calf_raise", + "name": "Single-Leg Dumbbell Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "donkey_calf_raise_machine", + "name": "Donkey Calf Raise Machine", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "leg_press_calf_raise", + "name": "Leg Press Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Leg Press Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_calf_raise_machine", + "name": "Seated Calf Raise Machine", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_calf_raise_machine", + "name": "Standing Calf Raise Machine", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_tibialis_raise", + "name": "Band Tibialis Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_seated_calf_raise", + "name": "Smith Machine Seated Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_standing_calf_raise", + "name": "Smith Machine Standing Calf Raise", + "primaryMuscle": "Calves", + "primaryMuscles": [ + "Calves" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_bench_press", + "name": "Barbell Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bench_press", + "name": "Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_bench_press", + "name": "Decline Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "feet_up_bench_press", + "name": "Feet-up Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_bench_press", + "name": "Incline Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Triceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "larsen_press", + "name": "Larsen Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "paused_bench_press", + "name": "Paused Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pin_press", + "name": "Pin Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_grip_bench_press", + "name": "Reverse-Grip Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "spoto_press", + "name": "Spoto Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "archer_push_up", + "name": "Archer Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "aztec_push_up", + "name": "Aztec Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "clap_push_up", + "name": "Clap Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "deficit_push_up", + "name": "Deficit Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dive_bomber_push_up", + "name": "Dive Bomber Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Lower back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "eccentric_push_up", + "name": "Eccentric Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "explosive_push_up", + "name": "Explosive Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hindu_push_up", + "name": "Hindu Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Lower back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "knee_push_up", + "name": "Knee Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Kneeling Push-up" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "negative_push_up", + "name": "Negative Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "offset_push_up", + "name": "Offset Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "planche_lean_push_up", + "name": "Planche Lean Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Triceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pseudo_planche_push_up", + "name": "Pseudo Planche Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Triceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "push_up_with_single_leg_raise", + "name": "Push-up With Single-Leg Raise", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Glutes", + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Single-Leg Push-up" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ring_archer_push_up", + "name": "Ring Archer Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ring_fly", + "name": "Ring Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Biceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [ + "Ring Chest Fly" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ring_push_up", + "name": "Ring Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "spiderman_push_up", + "name": "Spiderman Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "staggered_push_up", + "name": "Staggered Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "superman_push_up", + "name": "Superman Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "typewriter_push_up", + "name": "Typewriter Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wall_push_up", + "name": "Wall Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Wall Push-up" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "weighted_vest_push_up", + "name": "Weighted Vest Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Weighted Vest", + "Floor" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": true, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_exrx", + "source_wger" + ] + }, + { + "id": "cable_chest_fly_to_press", + "name": "Cable Chest Fly to Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Triceps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_press_around", + "name": "Cable Press-Around", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Triceps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "high_to_low_cable_fly", + "name": "High-to-Low Cable Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "low_to_high_cable_fly", + "name": "Low-to-High Cable Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "mid_cable_fly", + "name": "Mid Cable Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_cable_chest_fly", + "name": "Seated Cable Chest Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_arm_cable_fly", + "name": "Single-Arm Cable Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_arm_cable_press_around", + "name": "Single-Arm Cable Press-Around", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Triceps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_cable_chest_fly", + "name": "Standing Cable Chest Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_dumbbell_chest_fly", + "name": "Decline Dumbbell Chest Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_dumbbell_chest_press", + "name": "Decline Dumbbell Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_chest_fly", + "name": "Dumbbell Chest Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_chest_press", + "name": "Dumbbell Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_guillotine_press", + "name": "Dumbbell Guillotine Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Triceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_hex_press", + "name": "Dumbbell Hex Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_squeeze_press", + "name": "Dumbbell Squeeze Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "flat_dumbbell_fly", + "name": "Flat Dumbbell Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "flat_dumbbell_press", + "name": "Flat Dumbbell Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_dumbbell_chest_fly", + "name": "Incline Dumbbell Chest Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_dumbbell_chest_press", + "name": "Incline Dumbbell Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Triceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "low_incline_dumbbell_press", + "name": "Low-Incline Dumbbell Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Triceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kneeling_landmine_chest_press", + "name": "Kneeling Landmine Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Triceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "landmine_chest_press", + "name": "Landmine Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Triceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "converging_chest_press", + "name": "Converging Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_machine_chest_press", + "name": "Decline Machine Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_machine_chest_press", + "name": "Incline Machine Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Triceps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_chest_fly", + "name": "Machine Chest Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pec_deck_fly", + "name": "Pec Deck Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plate_loaded_chest_press", + "name": "Plate-Loaded Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "medicine_ball_push_up", + "name": "Medicine Ball Push-up", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "rotational_medicine_ball_chest_pass", + "name": "Rotational Medicine Ball Chest Pass", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Abdominals", + "Shoulders" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "plate_press", + "name": "Plate Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Triceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_chest_fly", + "name": "Band Chest Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_chest_press", + "name": "Band Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "banded_push_up", + "name": "Banded Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_bench_press", + "name": "Smith Machine Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_incline_press", + "name": "Smith Machine Incline Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Triceps" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_reverse_grip_bench_press", + "name": "Smith Machine Reverse-Grip Bench Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stability_ball_push_up", + "name": "Stability Ball Push-up", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Stability Ball", + "apparatus": [ + "Stability Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trx_chest_fly", + "name": "TRX Chest Fly", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trx_chest_press", + "name": "TRX Chest Press", + "primaryMuscle": "Chest", + "primaryMuscles": [ + "Chest" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "battle_rope_alternating_waves", + "name": "Battle Rope Alternating Waves", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Battle Rope", + "apparatus": [ + "Battle Rope" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "battle_rope_circles", + "name": "Battle Rope Circles", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Battle Rope", + "apparatus": [ + "Battle Rope" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "battle_rope_double_waves", + "name": "Battle Rope Double Waves", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Conditioning", + "equipmentDetail": "Battle Rope", + "apparatus": [ + "Battle Rope" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "battle_rope_jumping_jacks", + "name": "Battle Rope Jumping Jacks", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Shoulders", + "Calves" + ], + "equipment": "Conditioning", + "equipmentDetail": "Battle Rope", + "apparatus": [ + "Battle Rope" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "battle_rope_slams", + "name": "Battle Rope Slams", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals", + "Lats" + ], + "equipment": "Conditioning", + "equipmentDetail": "Battle Rope", + "apparatus": [ + "Battle Rope" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "burpee", + "name": "Burpee", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Chest", + "Quadriceps", + "Glutes", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "burpee_broad_jump", + "name": "Burpee Broad Jump", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes", + "Chest", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "burpee_tuck_jump", + "name": "Burpee Tuck Jump", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes", + "Chest", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "butt_kicks", + "name": "Butt Kicks", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Hamstrings", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "crawl_to_push_up", + "name": "Crawl to Push-up", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals", + "Quadriceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cross_jack", + "name": "Cross Jack", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Adductors", + "Abductors", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "fast_feet", + "name": "Fast Feet", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Calves", + "Quadriceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "frogger", + "name": "Frogger", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes", + "Abdominals", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "half_burpee", + "name": "Half Burpee", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Abdominals", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "high_knees", + "name": "High Knees", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Quadriceps", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "jumping_jack", + "name": "Jumping Jack", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Calves", + "Shoulders", + "Abductors" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lateral_shuffle", + "name": "Lateral Shuffle", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Abductors", + "Adductors", + "Quadriceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "mountain_climbers", + "name": "Mountain Climber", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Abdominals", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [ + "Mountain Climbers" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "push_up_burpee", + "name": "Push-up Burpee", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Chest", + "Quadriceps", + "Glutes", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "running_in_place", + "name": "Running In Place", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Calves", + "Quadriceps", + "Hamstrings" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seal_jack", + "name": "Seal Jack", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sprawl", + "name": "Sprawl", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Abdominals", + "Shoulders", + "Quadriceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "squat_thrust", + "name": "Squat Thrust", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Abdominals", + "Quadriceps", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "assault_bike", + "name": "Assault Bike", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Quadriceps", + "Shoulders" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "double_under", + "name": "Double Under", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Calves", + "Shoulders" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "elliptical", + "name": "Elliptical", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_treadmill_walk", + "name": "Incline Treadmill Walk", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Calves", + "Glutes" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "jump_rope", + "name": "Jump Rope", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Calves", + "Shoulders" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "outdoor_run", + "name": "Outdoor Run", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Abdominals", + "Forearms" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rowing_machine", + "name": "Rowing Machine", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Middle back", + "Lats", + "Quadriceps", + "Glutes" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "spin_bike", + "name": "Spin Bike", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes", + "Calves" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sprint", + "name": "Sprint", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Hamstrings", + "Glutes", + "Calves" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stair_climber", + "name": "Stair Climber", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes", + "Calves" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stationary_bike", + "name": "Stationary Bike", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes", + "Calves" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "treadmill_run", + "name": "Treadmill Run", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Abdominals", + "Forearms" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "treadmill_walk", + "name": "Treadmill Walk", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Abdominals", + "Forearms" + ], + "equipment": "Conditioning", + "equipmentDetail": "Cardio Machine", + "apparatus": [ + "Cardio Machine" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "5_10_5_shuttle", + "name": "5-10-5 Shuttle", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes", + "Calves" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Other" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "agility_ladder_high_knees", + "name": "Agility Ladder High Knees", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Calves" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Other" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "agility_ladder_ickey_shuffle", + "name": "Agility Ladder Ickey Shuffle", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Calves", + "Quadriceps" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Other" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "agility_ladder_lateral_shuffle", + "name": "Agility Ladder Lateral Shuffle", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Abductors", + "Adductors", + "Calves" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Other" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "cone_shuttle_run", + "name": "Cone Shuttle Run", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes", + "Calves" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Other" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "burpee_pull_up", + "name": "Burpee Pull-up", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Lats", + "Chest", + "Quadriceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "prowler_push", + "name": "Prowler Push", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Quadriceps", + "Glutes", + "Calves" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sled_pull", + "name": "Sled Pull", + "primaryMuscle": "Conditioning", + "primaryMuscles": [ + "Conditioning" + ], + "secondaryMuscles": [ + "Hamstrings", + "Glutes", + "Middle back" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bear_crawl", + "name": "Bear Crawl", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Quadriceps", + "Chest" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bear_crawl_shoulder_tap", + "name": "Bear Crawl Shoulder Tap", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Quadriceps", + "Chest" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bear_plank", + "name": "Bear Plank", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Quadriceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bird_dog_crunch", + "name": "Bird Dog Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "boat_pose_hold", + "name": "Boat Pose Hold", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "boat_pose_rocks", + "name": "Boat Pose Rocks", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "candlestick_roll", + "name": "Candlestick Roll", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "crab_toe_touch", + "name": "Crab Toe Touch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Triceps", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dead_bug_heel_tap", + "name": "Dead Bug Heel Tap", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dragon_flag", + "name": "Dragon Flag", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Lats" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dynamic_side_plank", + "name": "Dynamic Side Plank", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hanging_l_sit_hold", + "name": "Hanging L-Sit Hold", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Lats", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "high_plank", + "name": "High Plank", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Triceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hollow_body_flutter_kick", + "name": "Hollow Body Flutter Kick", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hollow_body_hold", + "name": "Hollow Body Hold", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hollow_body_rock", + "name": "Hollow Body Rock", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hollow_to_arch_roll", + "name": "Hollow-to-Arch Roll", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Lower back", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "knee_plank", + "name": "Knee Plank", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Chest" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "l_sit_hold", + "name": "L-Sit Hold", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lateral_bear_crawl", + "name": "Lateral Bear Crawl", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Quadriceps", + "Chest" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "long_lever_plank", + "name": "Long-Lever Plank", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Chest" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plank_in_out", + "name": "Plank In-Out", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [ + "Plank In and Out" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plank_jack", + "name": "Plank Jack", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Chest" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plank_shoulder_tap", + "name": "Plank Shoulder Tap", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Chest" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plank_skiers", + "name": "Plank Skiers", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plank_up_down", + "name": "Plank Up-Down", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Triceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [ + "Plank up-down", + "Plank-up" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rkc_plank", + "name": "RKC Plank", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Glutes", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_plank", + "name": "Reverse Plank", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_plank_leg_lift", + "name": "Reverse Plank Leg Lift", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_plank_march", + "name": "Reverse Plank March", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "slow_mountain_climber", + "name": "Slow Mountain Climber", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "straddle_v_up", + "name": "Straddle V-up", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Adductors" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "toes_to_bar", + "name": "Toes-to-Bar", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Lats", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tuck_dragon_flag", + "name": "Tuck Dragon Flag", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Lats" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tuck_l_sit_hold", + "name": "Tuck L-Sit Hold", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "v_sit_hold", + "name": "V-Sit Hold", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Triceps", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "v_up", + "name": "V-up", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [ + "V-Up" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "walkout", + "name": "Walkout", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Hamstrings", + "Shoulders", + "Chest" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [ + "Inchworm Walkout" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_chop", + "name": "Cable Chop", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_crunch_with_rope", + "name": "Cable Crunch With Rope", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_hip_flexor_raise", + "name": "Cable Hip Flexor Raise", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_lift", + "name": "Cable Lift", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_pallof_press", + "name": "Cable Pallof Press", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_side_bend", + "name": "Cable Side Bend", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_wood_chop", + "name": "Cable Wood Chop", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "half_kneeling_pallof_press", + "name": "Half-Kneeling Pallof Press", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Glutes", + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "high_to_low_cable_wood_chop", + "name": "High-to-Low Cable Wood Chop", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "horizontal_cable_wood_chop", + "name": "Horizontal Cable Wood Chop", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kneeling_cable_crunch", + "name": "Kneeling Cable Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "low_to_high_cable_wood_chop", + "name": "Low-to-High Cable Wood Chop", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pallof_press_iso_hold", + "name": "Pallof Press Iso Hold", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_cable_crunch", + "name": "Standing Cable Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_cable_hip_flexion", + "name": "Standing Cable Hip Flexion", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dead_bug_with_dumbbells", + "name": "Dead Bug With Dumbbells", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "suitcase_carry", + "name": "Suitcase Carry", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Forearms", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_rack_carry", + "name": "Front Rack Carry", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Forearms", + "Middle back", + "Glutes" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_around_the_world", + "name": "Kettlebell Around the World", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Core", + "Forearms" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_rack_carry", + "name": "Kettlebell Rack Carry", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Forearms", + "Middle back" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_suitcase_carry", + "name": "Kettlebell Suitcase Carry", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Forearms", + "Traps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "landmine_180s", + "name": "Landmine 180", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Lats" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [ + "Landmine 180's" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "landmine_anti_rotation", + "name": "Landmine Anti-Rotation", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "landmine_anti_rotation_press", + "name": "Landmine Anti-Rotation Press", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "captain_s_chair_knee_raise", + "name": "Captain's Chair Knee Raise", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "captain_s_chair_leg_raise", + "name": "Captain's Chair Leg Raise", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_ab_crunch", + "name": "Machine Ab Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rotary_torso_machine", + "name": "Rotary Torso Machine", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "medicine_ball_dead_bug", + "name": "Medicine Ball Dead Bug", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "medicine_ball_russian_twist", + "name": "Medicine Ball Russian Twist", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Obliques" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "medicine_ball_sit_up", + "name": "Medicine Ball Sit-up", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "medicine_ball_slam", + "name": "Medicine Ball Slam", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Lats", + "Shoulders" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "medicine_ball_v_up", + "name": "Medicine Ball V-up", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "overhead_medicine_ball_slam", + "name": "Overhead Medicine Ball Slam", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Lats", + "Shoulders" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "rotational_medicine_ball_slam", + "name": "Rotational Medicine Ball Slam", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Conditioning", + "equipmentDetail": "Medicine Ball", + "apparatus": [ + "Medicine Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "ab_roller", + "name": "Ab Wheel Rollout", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Lats" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Ab Wheel" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": true, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [ + "Ab Roller" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger", + "source_exrx" + ] + }, + { + "id": "kneeling_ab_wheel_rollout", + "name": "Kneeling Ab Wheel Rollout", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Lats" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Ab Wheel" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": true, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger", + "source_exrx" + ] + }, + { + "id": "standing_ab_wheel_rollout", + "name": "Standing Ab Wheel Rollout", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Lats" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Ab Wheel" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": true, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger", + "source_exrx" + ] + }, + { + "id": "plate_around_the_world", + "name": "Plate Around-the-World", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "weighted_plank", + "name": "Weighted Plank", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hanging_knee_raise", + "name": "Hanging Knee Raise", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ring_l_sit", + "name": "Ring L-Sit", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Triceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Rings", + "apparatus": [ + "Rings" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sandbag_bear_hug_carry", + "name": "Sandbag Bear-Hug Carry", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Glutes", + "Middle back", + "Forearms" + ], + "equipment": "Other", + "equipmentDetail": "Sandbag", + "apparatus": [ + "Sandbag" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sandbag_shoulder_carry", + "name": "Sandbag Shoulder Carry", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Glutes", + "Middle back" + ], + "equipment": "Other", + "equipmentDetail": "Sandbag", + "apparatus": [ + "Sandbag" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stability_ball_dead_bug", + "name": "Stability Ball Dead Bug", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Stability Ball", + "apparatus": [ + "Stability Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stability_ball_jackknife", + "name": "Stability Ball Jackknife", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Stability Ball", + "apparatus": [ + "Stability Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stability_ball_pike", + "name": "Stability Ball Pike", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Stability Ball", + "apparatus": [ + "Stability Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stability_ball_rollout", + "name": "Stability Ball Rollout", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Lats", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Stability Ball", + "apparatus": [ + "Stability Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stability_ball_stir_the_pot", + "name": "Stability Ball Stir-the-Pot", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Stability Ball", + "apparatus": [ + "Stability Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trx_body_saw", + "name": "TRX Body Saw", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trx_fallout", + "name": "TRX Fallout", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Lats", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trx_knee_tuck", + "name": "TRX Knee Tuck", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trx_pike", + "name": "TRX Pike", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_reverse_wrist_curl", + "name": "Barbell Reverse Wrist Curl", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_wrist_curl", + "name": "Barbell Wrist Curl", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dead_hang", + "name": "Dead Hang", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Lats", + "Middle back", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wrist_push_up_rock", + "name": "Wrist Push-up Rock", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_reverse_wrist_curl", + "name": "Cable Reverse Wrist Curl", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_farmer_carry", + "name": "Dumbbell Farmer Carry", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_pronation", + "name": "Dumbbell Pronation", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_reverse_wrist_curl", + "name": "Dumbbell Reverse Wrist Curl", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_supination", + "name": "Dumbbell Supination", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_wrist_curl", + "name": "Dumbbell Wrist Curl", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "farmer_carry", + "name": "Farmer Carry", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "farmer_s_carry", + "name": "Farmer's Carry", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "fat_grip_dumbbell_hold", + "name": "Fat Grip Dumbbell Hold", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_farmer_carry", + "name": "Kettlebell Farmer Carry", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "gripper_crush", + "name": "Gripper Crush", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Hand Gripper" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hammer_pronation", + "name": "Hammer Pronation", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Lever Hammer" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hammer_supination", + "name": "Hammer Supination", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Lever Hammer" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rice_bucket_wrist_extension", + "name": "Rice Bucket Wrist Extension", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Rice Bucket" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rice_bucket_wrist_flexion", + "name": "Rice Bucket Wrist Flexion", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Rice Bucket" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plate_pinch_carry", + "name": "Plate Pinch Carry", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plate_wrist_curl", + "name": "Plate Wrist Curl", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plate_wrist_extension", + "name": "Plate Wrist Extension", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "towel_hang", + "name": "Towel Hang", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Lats", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trap_bar_farmer_carry", + "name": "Trap Bar Farmer Carry", + "primaryMuscle": "Forearms", + "primaryMuscles": [ + "Forearms" + ], + "secondaryMuscles": [ + "Traps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Trap Bar", + "apparatus": [ + "Trap Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hang_power_clean", + "name": "Hang Power Clean", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Hamstrings", + "Glutes", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "hang_power_snatch", + "name": "Hang Power Snatch", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Shoulders", + "Glutes", + "Hamstrings" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "thruster", + "name": "Thruster", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Quadriceps", + "Shoulders", + "Triceps", + "Glutes" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "devil_press", + "name": "Devil Press", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Hamstrings", + "Glutes" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "dumbbell_clean_and_press", + "name": "Dumbbell Clean and Press", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Shoulders", + "Glutes", + "Hamstrings" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "dumbbell_snatch", + "name": "Dumbbell Snatch", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Shoulders", + "Hamstrings", + "Glutes" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "dumbbell_thruster", + "name": "Dumbbell Thruster", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Quadriceps", + "Shoulders", + "Triceps", + "Glutes" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "single_arm_dumbbell_snatch", + "name": "Single-Arm Dumbbell Snatch", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Shoulders", + "Hamstrings", + "Glutes" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "double_kettlebell_clean", + "name": "Double Kettlebell Clean", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Hamstrings", + "Glutes", + "Shoulders" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "kettlebell_clean", + "name": "Kettlebell Clean", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Hamstrings", + "Glutes", + "Shoulders" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "kettlebell_clean_and_press", + "name": "Kettlebell Clean and Press", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Shoulders", + "Hamstrings", + "Glutes" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "kettlebell_snatch", + "name": "Kettlebell Snatch", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Shoulders", + "Hamstrings", + "Glutes" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "kettlebell_turkish_get_up", + "name": "Kettlebell Turkish Get-up", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Abdominals", + "Shoulders", + "Glutes", + "Quadriceps", + "Hamstrings", + "Traps", + "Lats" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_strengthlog", + "source_wger" + ] + }, + { + "id": "single_arm_kettlebell_clean", + "name": "Single-Arm Kettlebell Clean", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Hamstrings", + "Glutes", + "Shoulders" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "single_arm_kettlebell_snatch", + "name": "Single-Arm Kettlebell Snatch", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Shoulders", + "Hamstrings", + "Glutes" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "turkish_get_up", + "name": "Turkish Get-up", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Abdominals", + "Shoulders", + "Glutes", + "Quadriceps", + "Hamstrings", + "Traps", + "Lats" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_strengthlog", + "source_wger" + ] + }, + { + "id": "landmine_clean_and_press", + "name": "Landmine Clean and Press", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Shoulders", + "Glutes", + "Hamstrings" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "landmine_rotation_press", + "name": "Landmine Rotation Press", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Glutes" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "landmine_thruster", + "name": "Landmine Thruster", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Quadriceps", + "Shoulders", + "Triceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "atlas_stone_load", + "name": "Atlas Stone Load", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Other" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plate_ground_to_overhead", + "name": "Plate Ground-to-Overhead", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Shoulders", + "Glutes", + "Hamstrings" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sandbag_clean", + "name": "Sandbag Clean", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Middle back" + ], + "equipment": "Other", + "equipmentDetail": "Sandbag", + "apparatus": [ + "Sandbag" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_wger" + ] + }, + { + "id": "sandbag_over_shoulder_toss", + "name": "Sandbag Over Shoulder Toss", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Other", + "equipmentDetail": "Sandbag", + "apparatus": [ + "Sandbag" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sandbag_shouldering", + "name": "Sandbag Shouldering", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Other", + "equipmentDetail": "Sandbag", + "apparatus": [ + "Sandbag" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "90_90_hip_switch", + "name": "90/90 Hip Switch", + "primaryMuscle": "Mobility", + "primaryMuscles": [ + "Mobility" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [ + "90/90 Hip Rotation" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "bodyweight_hip_thrust", + "name": "Bodyweight Hip Thrust", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "curtsy_lunge", + "name": "Curtsy Lunge", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Quadriceps", + "Adductors", + "Abductors" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "donkey_kick", + "name": "Donkey Kick", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "feet_elevated_glute_bridge", + "name": "Feet-Elevated Glute Bridge", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "frog_pump", + "name": "Frog Pump", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Adductors" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "glute_bridge", + "name": "Glute Bridge", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "glute_bridge_march", + "name": "Glute Bridge March", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "high_step_up", + "name": "High Step-up", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Quadriceps", + "Hamstrings" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hip_airplane", + "name": "Hip Airplane", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Abdominals", + "Abductors" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lateral_skater_hop", + "name": "Lateral Skater Hop", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Quadriceps", + "Abductors", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lateral_step_up", + "name": "Lateral Step-up", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Quadriceps", + "Abductors", + "Hamstrings" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pigeon_stretch", + "name": "Pigeon Stretch", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_hip_thrust", + "name": "Single-Leg Hip Thrust", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "skater_jump", + "name": "Skater Jump", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Quadriceps", + "Abductors", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sprinter_pull", + "name": "Sprinter Pull", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Quadriceps", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_glute_kickback", + "name": "Cable Glute Kickback", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_pull_through", + "name": "Cable Pull-Through", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Lower back" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_standing_glute_pushdown", + "name": "Cable Standing Glute Pushdown", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_frog_pump", + "name": "Dumbbell Frog Pump", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_glute_bridge", + "name": "Dumbbell Glute Bridge", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_hip_thrust", + "name": "Dumbbell Hip Thrust", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "45_degree_back_extension", + "name": "45-Degree Back Extension", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Lower back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "glute_focused_back_extension", + "name": "Glute-Focused Back Extension", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Lower back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_hip_thrust", + "name": "Machine Hip Thrust", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_hyperextension_machine", + "name": "Reverse Hyperextension Machine", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Lower back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "banded_glute_bridge", + "name": "Banded Glute Bridge", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings", + "Abductors" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_hip_thrust", + "name": "Smith Machine Hip Thrust", + "primaryMuscle": "Glutes", + "primaryMuscles": [ + "Glutes" + ], + "secondaryMuscles": [ + "Hamstrings" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "b_stance_romanian_deadlift", + "name": "B-Stance Romanian Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "RDL" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_romanian_deadlift", + "name": "Barbell Romanian Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "RDL" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_stiff_leg_deadlift", + "name": "Barbell Stiff-Leg Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "conventional_deadlift", + "name": "Conventional Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "deadlift", + "name": "Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "jefferson_curl", + "name": "Jefferson Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Lower back" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "snatch_grip_deadlift", + "name": "Snatch-Grip Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Middle back", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "assisted_nordic_hamstring_curl", + "name": "Assisted Nordic Hamstring Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bodyweight_good_morning", + "name": "Bodyweight Good Morning", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hamstring_slider_curl", + "name": "Hamstring Slider Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hamstring_walkout", + "name": "Hamstring Walkout", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hip_hinge_drill", + "name": "Hip Hinge Drill", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "inchworm_walkout", + "name": "Inchworm Walkout", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Abdominals", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_romanian_deadlift", + "name": "Single-Leg Romanian Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "RDL" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_sliding_hamstring_curl", + "name": "Single-Leg Sliding Hamstring Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sliding_hamstring_curl", + "name": "Sliding Hamstring Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_leg_curl", + "name": "Cable Leg Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_romanian_deadlift", + "name": "Cable Romanian Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "RDL" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_cable_leg_curl", + "name": "Standing Cable Leg Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "b_stance_dumbbell_romanian_deadlift", + "name": "B-Stance Dumbbell Romanian Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "RDL" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "duplicate_candidate" + ] + }, + { + "id": "dumbbell_good_morning", + "name": "Dumbbell Good Morning", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_romanian_deadlift", + "name": "Dumbbell Romanian Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "RDL" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_stiff_leg_deadlift", + "name": "Dumbbell Stiff-Leg Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_dumbbell_romanian_deadlift", + "name": "Single-Leg Dumbbell Romanian Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "RDL" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "american_kettlebell_swing", + "name": "American Kettlebell Swing", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Shoulders", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "double_kettlebell_swing", + "name": "Double Kettlebell Swing", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_romanian_deadlift", + "name": "Kettlebell Romanian Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "RDL" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_swing", + "name": "Kettlebell Swing", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "russian_kettlebell_swing", + "name": "Russian Kettlebell Swing", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_arm_kettlebell_swing", + "name": "Single-Arm Kettlebell Swing", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals", + "Forearms" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_leg_curl_machine", + "name": "Lying Leg Curl Machine", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_leg_curl_machine", + "name": "Seated Leg Curl Machine", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_lying_leg_curl", + "name": "Single-Leg Lying Leg Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_seated_leg_curl", + "name": "Single-Leg Seated Leg Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_leg_curl_machine", + "name": "Standing Leg Curl Machine", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "forward_sled_drag", + "name": "Forward Sled Drag", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_good_morning", + "name": "Smith Machine Good Morning", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_romanian_deadlift", + "name": "Smith Machine Romanian Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "RDL" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "stability_ball_leg_curl", + "name": "Stability Ball Leg Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Stability Ball", + "apparatus": [ + "Stability Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "swiss_ball_hamstring_curl", + "name": "Swiss Ball Hamstring Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Stability Ball", + "apparatus": [ + "Stability Ball" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trap_bar_romanian_deadlift", + "name": "Trap Bar Romanian Deadlift", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back" + ], + "equipment": "Barbell", + "equipmentDetail": "Trap Bar", + "apparatus": [ + "Trap Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "RDL" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "yates_row", + "name": "Yates Row", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Middle back", + "Biceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "active_hang", + "name": "Active Hang", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Forearms", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "advanced_tuck_front_lever_hold", + "name": "Advanced Tuck Front Lever Hold", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Abdominals", + "Middle back", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "arch_hang", + "name": "Arch Hang", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Middle back", + "Forearms", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Hollow-to-Arch Hang" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "archer_pull_up", + "name": "Archer Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "assisted_one_arm_pull_up", + "name": "Assisted One-Arm Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chest_to_bar_pull_up", + "name": "Chest-to-Bar Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Middle back", + "Biceps", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "climber_pull_up", + "name": "Climber Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "close_grip_pull_up", + "name": "Close-Grip Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "commando_pull_up", + "name": "Commando Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_lever_pull_up", + "name": "Front Lever Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_lever_raise", + "name": "Front Lever Raise", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Abdominals", + "Middle back", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "full_front_lever_hold", + "name": "Full Front Lever Hold", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Abdominals", + "Middle back", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "high_pull_up", + "name": "High Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Middle back", + "Biceps", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ice_cream_maker", + "name": "Ice Cream Maker", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "jumping_muscle_up", + "name": "Jumping Muscle-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Chest", + "Triceps", + "Biceps", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "jumping_pull_up", + "name": "Jumping Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "l_sit_pull_up", + "name": "L-Sit Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Abdominals", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "negative_muscle_up", + "name": "Negative Muscle-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Chest", + "Triceps", + "Biceps", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "negative_pull_up", + "name": "Negative Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "neutral_grip_pull_up", + "name": "Neutral-Grip Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "one_arm_pull_up", + "name": "One-Arm Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ring_muscle_up", + "name": "Ring Muscle-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Chest", + "Triceps", + "Biceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "slow_muscle_up", + "name": "Slow Muscle-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Chest", + "Triceps", + "Biceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "straddle_front_lever_hold", + "name": "Straddle Front Lever Hold", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Abdominals", + "Middle back", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "towel_pull_up", + "name": "Towel Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Forearms", + "Biceps", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tuck_front_lever_hold", + "name": "Tuck Front Lever Hold", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Abdominals", + "Middle back", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "typewriter_pull_up", + "name": "Typewriter Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wide_grip_pull_up", + "name": "Wide-Grip Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_pullover", + "name": "Cable Pullover", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Chest", + "Triceps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cross_body_lat_pull_around", + "name": "Cross-Body Lat Pull-Around", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "half_kneeling_single_arm_lat_pulldown", + "name": "Half-Kneeling Single-Arm Lat Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Lat Pulldown Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lat_pulldown", + "name": "Lat Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Lat Pulldown Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Pulldown" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "neutral_grip_lat_pulldown", + "name": "Neutral-Grip Lat Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Lat Pulldown Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_arm_lat_pulldown", + "name": "Single-Arm Lat Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Lat Pulldown Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "straight_arm_cable_pulldown", + "name": "Straight-Arm Cable Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "underhand_lat_pulldown", + "name": "Underhand Lat Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Lat Pulldown Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bench_supported_dumbbell_row", + "name": "Bench-Supported Dumbbell Row", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Middle back", + "Biceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_pullover", + "name": "Dumbbell Pullover", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Chest", + "Triceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_row", + "name": "Dumbbell Row", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Middle back", + "Biceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kroc_row", + "name": "Kroc Row", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Middle back", + "Biceps", + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tripod_dumbbell_row", + "name": "Tripod Dumbbell Row", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Middle back", + "Biceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_arm_kettlebell_row", + "name": "Single-Arm Kettlebell Row", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Middle back", + "Biceps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "assisted_chin_up_machine", + "name": "Assisted Chin-up Machine", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "assisted_pull_up", + "name": "Assisted Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hammer_strength_pulldown", + "name": "Hammer Strength Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_high_row", + "name": "Machine High Row", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Middle back", + "Biceps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_pullover", + "name": "Machine Pullover", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Triceps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plate_loaded_high_row", + "name": "Plate-Loaded High Row", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Middle back", + "Biceps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plate_loaded_lat_pulldown", + "name": "Plate-Loaded Lat Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Lat Pulldown Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pullover_machine", + "name": "Pullover Machine", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Chest", + "Triceps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "close_grip_chin_up", + "name": "Close-Grip Chin-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "weighted_chin_up", + "name": "Weighted Chin-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_lat_pulldown", + "name": "Band Lat Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_pull_over", + "name": "Band Pull-Over", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Chest", + "Triceps" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_straight_arm_pulldown", + "name": "Band Straight-Arm Pulldown", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Triceps" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_assisted_chin_up", + "name": "Band-Assisted Chin-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Middle back" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_good_mornings", + "name": "Seated Good Morning", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Hamstrings", + "Glutes" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [ + "Seated Good Mornings" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "back_extension", + "name": "Back Extension", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_back_extension", + "name": "Machine Back Extension", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "roman_chair_back_extension", + "name": "Roman Chair Back Extension", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "weighted_back_extension", + "name": "Weighted Back Extension", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "neck_extension_isometric", + "name": "Neck Extension Isometric", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "neck_flexion_isometric", + "name": "Neck Flexion Isometric", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "neck_lateral_flexion_isometric", + "name": "Neck Lateral Flexion Isometric", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "neck_machine_extension", + "name": "Neck Machine Extension", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "neck_machine_flexion", + "name": "Neck Machine Flexion", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "neck_machine_lateral_flexion", + "name": "Neck Machine Lateral Flexion", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "neck_harness_extension", + "name": "Neck Harness Extension", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Neck Harness" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "neck_harness_flexion", + "name": "Neck Harness Flexion", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Neck Harness" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lateral_neck_flexion", + "name": "Lateral Neck Flexion", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "neck_extension", + "name": "Neck Extension", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "neck_flexion", + "name": "Neck Flexion", + "primaryMuscle": "Neck", + "primaryMuscles": [ + "Neck" + ], + "secondaryMuscles": [], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "air_bike", + "name": "Bicycle Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [ + "Air Bike", + "Supine Bicycle Crunch" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "hanging_windshield_wiper", + "name": "Hanging Windshield Wiper", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Lats", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "high_plank_t_spine_rotation", + "name": "High Plank T-Spine Rotation", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders", + "Chest" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "human_flag_hold", + "name": "Human Flag Hold", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Lats", + "Shoulders", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lying_windshield_wiper", + "name": "Lying Windshield Wiper", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "mountain_climber_cross_body", + "name": "Mountain Climber Cross-Body", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plank_knee_to_elbow", + "name": "Plank Knee to Elbow", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "side_plank_hip_dip", + "name": "Side Plank Hip Dip", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "star_side_plank", + "name": "Star Side Plank", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Abductors", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tuck_human_flag_hold", + "name": "Tuck Human Flag Hold", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Lats", + "Shoulders", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "vertical_flag_hold", + "name": "Vertical Flag Hold", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [ + "Lats", + "Shoulders", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "isometric", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "anderson_squat", + "name": "Anderson Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "back_squat", + "name": "Back Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_bulgarian_split_squat", + "name": "Barbell Bulgarian Split Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Adductors" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_reverse_lunge", + "name": "Barbell Reverse Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_split_squat", + "name": "Barbell Split Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Adductors" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "front_squat", + "name": "Front Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Middle back", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "high_bar_back_squat", + "name": "High-Bar Back Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "low_bar_back_squat", + "name": "Low-Bar Back Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "paused_squat", + "name": "Paused Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pin_squat", + "name": "Pin Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tempo_squat", + "name": "Tempo Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Adductors", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "zercher_squats", + "name": "Zercher Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals", + "Middle back" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [ + "Zercher Squats" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "180_squat_jump", + "name": "180 Squat Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "assisted_pistol_squat", + "name": "Assisted Pistol Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "assisted_reverse_nordic_curl", + "name": "Assisted Reverse Nordic Curl", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "assisted_sissy_squat", + "name": "Assisted Sissy Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bodyweight_step_up", + "name": "Bodyweight Step-up", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "box_pistol_squat", + "name": "Box Pistol Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "box_step_down", + "name": "Box Step-down", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "broad_jump", + "name": "Broad Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cycled_split_squat_jump", + "name": "Cycled Split-Squat Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "deep_squat_hold", + "name": "Deep Squat Hold", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Adductors", + "Glutes", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "duck_walk", + "name": "Duck Walk", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "forward_lunge", + "name": "Forward Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hindu_squat", + "name": "Hindu Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Calves", + "Glutes", + "Hamstrings" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "jumping_lunge", + "name": "Jumping Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [ + "Lunge Jump" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "negative_pistol_squat", + "name": "Negative Pistol Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pistol_squat", + "name": "Pistol Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "prisoner_squat", + "name": "Prisoner Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_lunge", + "name": "Reverse Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_lunge_jump", + "name": "Reverse Lunge Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_lunge_to_knee_drive", + "name": "Reverse Lunge to Knee Drive", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_nordic_curl", + "name": "Reverse Nordic Curl", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_single_leg_stand_up", + "name": "Seated Single-Leg Stand-up", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "shrimp_squat", + "name": "Shrimp Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_box_squat", + "name": "Single-Leg Box Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_broad_jump", + "name": "Single-Leg Broad Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_squat", + "name": "Single-Leg Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_wall_sit", + "name": "Single-Leg Wall Sit", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Wall" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sissy_squat", + "name": "Sissy Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "skater_squat", + "name": "Skater Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "squat_jump", + "name": "Squat Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "squat_sky_reach", + "name": "Squat Sky Reach", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "static_lunge", + "name": "Static Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sumo_rotational_squat", + "name": "Sumo Rotational Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Adductors", + "Glutes", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sumo_squat", + "name": "Sumo Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Adductors", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tuck_jump", + "name": "Tuck Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Calves", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "walking_lunge", + "name": "Walking Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wall_sit", + "name": "Wall Sit", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Wall" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "squat", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "box_jump", + "name": "Box Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Box/Platform", + "apparatus": [ + "Box" + ], + "trackingType": "duration", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "depth_jump", + "name": "Depth Jump", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Box/Platform", + "apparatus": [ + "Box" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_belt_squat", + "name": "Cable Belt Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_goblet_squat", + "name": "Cable Goblet Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_reverse_lunge", + "name": "Cable Reverse Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cyclist_squat", + "name": "Cyclist Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_bulgarian_split_squat", + "name": "Dumbbell Bulgarian Split Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Adductors" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_front_squat", + "name": "Dumbbell Front Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_goblet_squat", + "name": "Dumbbell Goblet Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_reverse_lunge", + "name": "Dumbbell Reverse Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_split_squat", + "name": "Dumbbell Split Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Adductors" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_walking_lunge", + "name": "Dumbbell Walking Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "heel_elevated_goblet_squat", + "name": "Heel-Elevated Goblet Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "weighted_step_up", + "name": "Weighted Step-up", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "double_kettlebell_front_squat", + "name": "Double Kettlebell Front Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals", + "Middle back" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_front_squat", + "name": "Kettlebell Front Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_goblet_squat", + "name": "Kettlebell Goblet Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_reverse_lunge", + "name": "Kettlebell Reverse Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_step_up", + "name": "Kettlebell Step-up", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_walking_lunge", + "name": "Kettlebell Walking Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "duration_distance", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "landmine_hack_squat", + "name": "Landmine Hack Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "landmine_reverse_lunge", + "name": "Landmine Reverse Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "landmine_squat", + "name": "Landmine Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "45_degree_leg_press", + "name": "45-Degree Leg Press", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Leg Press Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "horizontal_leg_press", + "name": "Horizontal Leg Press", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Leg Press Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pendulum_squat", + "name": "Pendulum Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_leg_leg_press", + "name": "Single-Leg Leg Press", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Leg Press Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "v_squat_machine", + "name": "V-Squat Machine", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "vertical_leg_press", + "name": "Vertical Leg Press", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Leg Press Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_leg_extension", + "name": "Band Leg Extension", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_split_squat", + "name": "Band Split Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_squat", + "name": "Band Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "spanish_squat", + "name": "Spanish Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sandbag_bear_hug_squat", + "name": "Sandbag Bear-Hug Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Other", + "equipmentDetail": "Sandbag", + "apparatus": [ + "Sandbag" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "sandbag_front_squat", + "name": "Sandbag Front Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Other", + "equipmentDetail": "Sandbag", + "apparatus": [ + "Sandbag" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "backward_sled_drag", + "name": "Backward Sled Drag", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Calves" + ], + "equipment": "Conditioning", + "equipmentDetail": "Sled", + "apparatus": [ + "Sled" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_bulgarian_split_squat", + "name": "Smith Machine Bulgarian Split Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Adductors" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_front_squat", + "name": "Smith Machine Front Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_reverse_lunge", + "name": "Smith Machine Reverse Lunge", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_sissy_squat", + "name": "Smith Machine Sissy Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_split_squat", + "name": "Smith Machine Split Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Adductors" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_squat", + "name": "Smith Machine Squat", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Abdominals" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "squat", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "prone_t_raise", + "name": "Prone T Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "prone_w_raise", + "name": "Prone W Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "prone_y_raise", + "name": "Prone Y Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Lower back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ring_face_pull", + "name": "Ring Face Pull", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Biceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ring_rear_delt_row", + "name": "Ring Rear Delt Row", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Biceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_jerk", + "name": "Barbell Jerk", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Quadriceps", + "Glutes" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_push_press", + "name": "Barbell Push Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Quadriceps", + "Glutes" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "behind_the_neck_press", + "name": "Behind-the-Neck Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bradford_press", + "name": "Bradford Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "overhead_press", + "name": "Overhead Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Military Press", + "Shoulder Press" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "push_jerk", + "name": "Push Jerk", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Quadriceps", + "Glutes" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_barbell_overhead_press", + "name": "Seated Barbell Overhead Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_barbell_shoulder_press", + "name": "Seated Barbell Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "snatch_grip_behind_the_neck_press", + "name": "Snatch-Grip Behind-the-Neck Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Traps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "strict_press", + "name": "Strict Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "z_press", + "name": "Z Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "advanced_tuck_back_lever_hold", + "name": "Advanced Tuck Back Lever Hold", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Lats", + "Abdominals", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "advanced_tuck_planche_hold", + "name": "Advanced Tuck Planche Hold", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Triceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "back_lever_raise", + "name": "Back Lever Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Lats", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "box_pike_push_up", + "name": "Box Pike Push-up", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chest_to_wall_handstand_hold", + "name": "Chest-to-Wall Handstand Hold", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Wall" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "crow_pose_hold", + "name": "Crow Pose Hold", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "downward_dog", + "name": "Downward Dog", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Hamstrings", + "Calves", + "Lower back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "elevated_pike_push_up", + "name": "Elevated Pike Push-up", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "feet_elevated_pike_push_up", + "name": "Feet-Elevated Pike Push-up", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "frog_stand", + "name": "Frog Stand", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "full_back_lever_hold", + "name": "Full Back Lever Hold", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Lats", + "Abdominals", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "full_planche_hold", + "name": "Full Planche Hold", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Triceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "german_hang", + "name": "German Hang", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Lats", + "Biceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "handstand_hold", + "name": "Handstand Hold", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "handstand_shoulder_tap", + "name": "Handstand Shoulder Tap", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "negative_handstand_push_up", + "name": "Negative Handstand Push-up", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pike_push_up", + "name": "Pike Push-up", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Pike Press" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "planche_lean", + "name": "Planche Lean", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Triceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "planche_push_up", + "name": "Planche Push-up", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Triceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "scapular_push_up", + "name": "Scapular Push-up", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "skin_the_cat", + "name": "Skin the Cat", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Lats", + "Abdominals", + "Chest" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "straddle_back_lever_hold", + "name": "Straddle Back Lever Hold", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Lats", + "Abdominals", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "straddle_planche_hold", + "name": "Straddle Planche Hold", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Triceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tuck_back_lever_hold", + "name": "Tuck Back Lever Hold", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Lats", + "Abdominals", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tuck_planche_hold", + "name": "Tuck Planche Hold", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Triceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "tuck_planche_push_up", + "name": "Tuck Planche Push-up", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Triceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wall_angel", + "name": "Wall Angel", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Wall" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wall_handstand_hold", + "name": "Wall Handstand Hold", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals", + "Forearms" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Wall" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wall_handstand_push_up", + "name": "Wall Handstand Push-up", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Chest", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wall_slide", + "name": "Wall Slide", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Wall" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wall_walk", + "name": "Wall Walk", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals", + "Chest" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Wall" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "behind_the_back_cable_lateral_raise", + "name": "Behind-the-Back Cable Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_external_rotation", + "name": "Cable External Rotation", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_face_pull", + "name": "Cable Face Pull", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_front_raise", + "name": "Cable Front Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_lateral_raise", + "name": "Cable Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_upright_row", + "name": "Cable Upright Row", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Biceps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_y_raise", + "name": "Cable Y Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cross_body_cable_lateral_raise", + "name": "Cross-Body Cable Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "leaning_cable_lateral_raise", + "name": "Leaning Cable Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_cable_fly", + "name": "Reverse Cable Fly", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_arm_cable_lateral_raise", + "name": "Single-Arm Cable Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "alternating_dumbbell_front_raise", + "name": "Alternating Dumbbell Front Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "alternating_dumbbell_shoulder_press", + "name": "Alternating Dumbbell Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "arnold_press", + "name": "Arnold Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bent_over_dumbbell_reverse_fly", + "name": "Bent-Over Dumbbell Reverse Fly", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chest_supported_dumbbell_rear_delt_row", + "name": "Chest-Supported Dumbbell Rear Delt Row", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Biceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_external_rotation", + "name": "Dumbbell External Rotation", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_front_raise", + "name": "Dumbbell Front Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_internal_rotation", + "name": "Dumbbell Internal Rotation", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_lateral_raise", + "name": "Dumbbell Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_push_press", + "name": "Dumbbell Push Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Quadriceps", + "Glutes" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_rear_delt_fly", + "name": "Dumbbell Rear Delt Fly", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_upright_row", + "name": "Dumbbell Upright Row", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Biceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_z_press", + "name": "Dumbbell Z Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "high_incline_dumbbell_press", + "name": "High-Incline Dumbbell Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Triceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_dumbbell_rear_delt_fly", + "name": "Incline Dumbbell Rear Delt Fly", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "leaning_dumbbell_lateral_raise", + "name": "Leaning Dumbbell Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "lu_raise", + "name": "Lu Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "monkey_row", + "name": "Monkey Row", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "neutral_grip_dumbbell_shoulder_press", + "name": "Neutral-Grip Dumbbell Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "overhead_carry", + "name": "Overhead Carry", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals", + "Forearms" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "powell_raise", + "name": "Powell Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_dumbbell_lateral_raise", + "name": "Seated Dumbbell Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_dumbbell_shoulder_press", + "name": "Seated Dumbbell Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_arm_dumbbell_shoulder_press", + "name": "Single-Arm Dumbbell Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "standing_dumbbell_shoulder_press", + "name": "Standing Dumbbell Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bottoms_up_kettlebell_press", + "name": "Bottoms-up Kettlebell Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Forearms", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_halo", + "name": "Kettlebell Halo", + "primaryMuscle": "Mobility", + "primaryMuscles": [ + "Mobility" + ], + "secondaryMuscles": [ + "Shoulders", + "Core", + "Traps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_press", + "name": "Kettlebell Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_push_press", + "name": "Kettlebell Push Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Quadriceps", + "Glutes" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_arm_kettlebell_press", + "name": "Single-Arm Kettlebell Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "waiter_carry", + "name": "Waiter Carry", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals", + "Forearms" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "carry", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "half_kneeling_landmine_press", + "name": "Half-Kneeling Landmine Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Triceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "landmine_press", + "name": "Landmine Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Triceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "landmine_push_press", + "name": "Landmine Push Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Quadriceps", + "Glutes" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "one_arm_landmine_press", + "name": "One-Arm Landmine Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest", + "Triceps", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "clubbell_mill", + "name": "Clubbell Mill", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Core", + "Forearms", + "Shoulders" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Clubbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "clubbell_shield_cast", + "name": "Clubbell Shield Cast", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Core", + "Forearms", + "Shoulders" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Clubbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "steel_mace_10_to_2", + "name": "Steel Mace 10-to-2", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Core", + "Forearms", + "Shoulders" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Steel Mace" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "steel_mace_360", + "name": "Steel Mace 360", + "primaryMuscle": "Full Body", + "primaryMuscles": [ + "Full Body" + ], + "secondaryMuscles": [ + "Core", + "Forearms", + "Shoulders" + ], + "equipment": "Other", + "equipmentDetail": "Other", + "apparatus": [ + "Steel Mace" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_rear_delt_fly", + "name": "Machine Rear Delt Fly", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_shoulder_press", + "name": "Machine Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plate_loaded_shoulder_press", + "name": "Plate-Loaded Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plate_front_raise", + "name": "Plate Front Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plate_halo", + "name": "Plate Halo", + "primaryMuscle": "Mobility", + "primaryMuscles": [ + "Mobility" + ], + "secondaryMuscles": [ + "Shoulders", + "Core" + ], + "equipment": "Barbell", + "equipmentDetail": "Plate", + "apparatus": [ + "Weight Plate" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_external_rotation", + "name": "Band External Rotation", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_face_pull", + "name": "Band Face Pull", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_front_raise", + "name": "Band Front Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Chest" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_internal_rotation", + "name": "Band Internal Rotation", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "rotation", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_lateral_raise", + "name": "Band Lateral Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_behind_the_neck_press", + "name": "Smith Machine Behind-the-Neck Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps", + "Traps" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_shoulder_press", + "name": "Smith Machine Shoulder Press", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Triceps" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trx_face_pull", + "name": "TRX Face Pull", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trx_t_raise", + "name": "TRX T Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trx_y_raise", + "name": "TRX Y Raise", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Middle back", + "Traps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "arch_body_hold", + "name": "Arch Body Hold", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bird_dog", + "name": "Bird Dog", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Abdominals", + "Glutes", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cat_cow", + "name": "Cat-Cow", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "child_pose_to_cobra", + "name": "Child Pose to Cobra", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Shoulders", + "Chest" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cobra_stretch", + "name": "Cobra Stretch", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Chest" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "superman_hold", + "name": "Superman Hold", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "superman_pull", + "name": "Superman Pull", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Lats", + "Shoulders", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "upward_dog", + "name": "Upward Dog", + "primaryMuscle": "Lower back", + "primaryMuscles": [ + "Lower back" + ], + "secondaryMuscles": [ + "Chest" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "behind_the_back_barbell_shrug", + "name": "Behind-the-Back Barbell Shrug", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_shrugs", + "name": "Cable Shrug", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [ + "Cable Shrugs" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_trap_3_raise", + "name": "Cable Trap-3 Raise", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Shoulders", + "Middle back" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_dumbbell_shrug", + "name": "Incline Dumbbell Shrug", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Middle back" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trap_3_raise", + "name": "Trap-3 Raise", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Shoulders", + "Middle back" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_high_pull", + "name": "Kettlebell High Pull", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Hamstrings", + "Glutes", + "Shoulders" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "olympic", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_shrug", + "name": "Smith Machine Shrug", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trap_bar_shrug", + "name": "Trap Bar Shrug", + "primaryMuscle": "Traps", + "primaryMuscles": [ + "Traps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Trap Bar", + "apparatus": [ + "Trap Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "isometric", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "barbell_skull_crusher", + "name": "Barbell Skull Crusher", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "close_grip_bench_press", + "name": "Close-Grip Bench Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "close_grip_floor_press", + "name": "Close-Grip Floor Press", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "bodyweight_triceps_extension", + "name": "Bodyweight Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Bodyweight Skull Crusher" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "duplicate_candidate" + ] + }, + { + "id": "crab_walk", + "name": "Crab Walk", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Glutes", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "cardio", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "diamond_knee_push_up", + "name": "Diamond Knee Push-up", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "diamond_push_up", + "name": "Diamond Push-up", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dip_support_hold", + "name": "Dip Support Hold", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Chest", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "jumping_dip", + "name": "Jumping Dip", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [ + "Jump Dip" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "korean_dip", + "name": "Korean Dip", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "negative_dip", + "name": "Negative Dip", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ring_support_hold", + "name": "Ring Support Hold", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders", + "Chest", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ring_triceps_extension", + "name": "Ring Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "russian_dip", + "name": "Russian Dip", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "straight_bar_dip", + "name": "Straight Bar Dip", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "weighted_dip", + "name": "Weighted Dip", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Dip Bars", + "apparatus": [ + "Dip Bars" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [ + "Weighted Dips" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_katana_extension", + "name": "Cable Katana Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_pressdown", + "name": "Cable Pressdown", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_skull_crusher", + "name": "Cable Skull Crusher", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_triceps_pushdown", + "name": "Cable Triceps Pushdown", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "crossbody_cable_triceps_extension", + "name": "Crossbody Cable Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_cable_triceps_extension", + "name": "Incline Cable Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "overhead_bar_cable_triceps_extension", + "name": "Overhead Bar Cable Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "overhead_cable_triceps_extension", + "name": "Overhead Cable Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "overhead_rope_cable_triceps_extension", + "name": "Overhead Rope Cable Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_grip_cable_pushdown", + "name": "Reverse-Grip Cable Pushdown", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Forearms" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rope_triceps_pushdown", + "name": "Rope Triceps Pushdown", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_arm_cable_pushdown", + "name": "Single-Arm Cable Pushdown", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_arm_overhead_cable_triceps_extension", + "name": "Single-Arm Overhead Cable Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "straight_bar_triceps_pushdown", + "name": "Straight-Bar Triceps Pushdown", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "v_bar_triceps_pushdown", + "name": "V-Bar Triceps Pushdown", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_lying_triceps_extension", + "name": "Dumbbell Lying Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_overhead_triceps_extension", + "name": "Dumbbell Overhead Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_skull_crusher", + "name": "Dumbbell Skull Crusher", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "dumbbell_triceps_kickback", + "name": "Dumbbell Triceps Kickback", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pjr_pullover", + "name": "PJR Pullover", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Lats", + "Chest" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rolling_dumbbell_triceps_extension", + "name": "Rolling Dumbbell Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_arm_dumbbell_overhead_triceps_extension", + "name": "Single-Arm Dumbbell Overhead Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "decline_ez_bar_skull_crusher", + "name": "Decline EZ-Bar Skull Crusher", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "EZ Bar", + "apparatus": [ + "EZ Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_ez_bar_triceps_extension", + "name": "Incline EZ-Bar Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Barbell", + "equipmentDetail": "EZ Bar", + "apparatus": [ + "EZ Bar" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "assisted_dip", + "name": "Assisted Dip", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "assisted_dip_machine", + "name": "Assisted Dip Machine", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_dip", + "name": "Machine Dip", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_overhead_triceps_extension", + "name": "Machine Overhead Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_overhead_triceps_extension", + "name": "Band Overhead Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_triceps_kickback", + "name": "Band Triceps Kickback", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "hinge", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_triceps_pushdown", + "name": "Band Triceps Pushdown", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "smith_machine_skull_crusher", + "name": "Smith Machine Skull Crusher", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Shoulders" + ], + "equipment": "Machine", + "equipmentDetail": "Smith Machine", + "apparatus": [ + "Smith Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trx_triceps_extension", + "name": "TRX Triceps Extension", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Abdominals", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "duplicate_candidate" + ] + }, + { + "id": "barbell_row", + "name": "Barbell Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps", + "Lower back" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "block_pull", + "name": "Block Pull", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Traps", + "Lower back", + "Glutes" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chest_supported_barbell_row", + "name": "Chest-Supported Barbell Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "deficit_pendlay_row", + "name": "Deficit Pendlay Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps", + "Lower back" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "rack_pulls", + "name": "Rack Pull", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Traps", + "Lower back", + "Glutes" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Rack Pulls" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "australian_chin_up", + "name": "Australian Chin-up", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "australian_pull_up", + "name": "Australian Pull-up", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Body Row" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "quadruped_t_spine_rotation", + "name": "Quadruped T-Spine Rotation", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "reverse_snow_angel", + "name": "Reverse Snow Angel", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Shoulders", + "Lower back", + "Glutes" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "ring_row", + "name": "Ring Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps", + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "table_row", + "name": "Table Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps", + "Shoulders" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "thread_the_needle", + "name": "Thread the Needle", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Shoulders", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "cable_row_to_neck", + "name": "Cable Row to Neck", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Shoulders", + "Traps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "close_grip_seated_cable_row", + "name": "Close-Grip Seated Cable Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "high_cable_row", + "name": "High Cable Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "low_cable_row", + "name": "Low Cable Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "seated_cable_row_neutral_grip", + "name": "Seated Cable Row - Neutral Grip", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_arm_cable_row", + "name": "Single-Arm Cable Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps", + "Abdominals" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "wide_grip_seated_cable_row", + "name": "Wide-Grip Seated Cable Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "incline_dumbbell_row", + "name": "Incline Dumbbell Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "renegade_row", + "name": "Renegade Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps", + "Abdominals" + ], + "equipment": "Dumbbell", + "equipmentDetail": "Dumbbell", + "apparatus": [ + "Dumbbells" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "double_kettlebell_row", + "name": "Double Kettlebell Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "gorilla_row", + "name": "Gorilla Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps", + "Abdominals" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "kettlebell_row", + "name": "Kettlebell Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Kettlebell", + "equipmentDetail": "Kettlebell", + "apparatus": [ + "Kettlebell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "landmine_row", + "name": "Landmine Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Barbell", + "equipmentDetail": "Landmine", + "apparatus": [ + "Landmine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "chest_supported_t_bar_row", + "name": "Chest-Supported T-Bar Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "hammer_strength_row", + "name": "Hammer Strength Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_low_row", + "name": "Machine Low Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "machine_row", + "name": "Machine Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plate_loaded_low_row", + "name": "Plate-Loaded Low Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "plate_loaded_row", + "name": "Plate-Loaded Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "t_bar_row", + "name": "T-Bar Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Machine", + "equipmentDetail": "Machine", + "apparatus": [ + "Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "band_row", + "name": "Band Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "single_arm_band_row", + "name": "Single-Arm Band Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps", + "Abdominals" + ], + "equipment": "Band", + "equipmentDetail": "Resistance Band", + "apparatus": [ + "Resistance Band" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trx_high_row", + "name": "TRX High Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Shoulders", + "Biceps" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "trx_row", + "name": "TRX Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Lats", + "Biceps", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Suspension/TRX", + "apparatus": [ + "TRX Straps" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "longhaul_upright_row_barbell", + "name": "Upright Row - Barbell", + "primaryMuscle": "Shoulders", + "primaryMuscles": [ + "Shoulders" + ], + "secondaryMuscles": [ + "Traps", + "Biceps", + "Forearms" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Barbell Upright Row", + "Upright Row" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "source_exrx" + ] + } + ], + "idMigrationMap": { + "34_situp": "34_situp", + "ab_crunch_machine": "ab_crunch_machine", + "ab_roller": "ab_roller", + "advanced_kettlebell_windmill": "advanced_kettlebell_windmill", + "air_bike": "air_bike", + "alternate_heel_touchers": "alternate_heel_touchers", + "barbell_ab_rollout": "barbell_ab_rollout", + "barbell_ab_rollout__on_knees": "barbell_ab_rollout__on_knees", + "barbell_rollout_from_bench": "barbell_rollout_from_bench", + "barbell_side_bend": "barbell_side_bend", + "bent_press": "bent_press", + "bentknee_hip_raise": "bentknee_hip_raise", + "bosu_ball_cable_crunch_with_side_bends": "bosu_ball_cable_crunch_with_side_bends", + "bottoms_up": "bottoms_up", + "buttups": "buttups", + "cable_crunch": "cable_crunch", + "cable_judo_flip": "cable_judo_flip", + "cable_reverse_crunch": "cable_reverse_crunch", + "cable_russian_twists": "cable_russian_twists", + "cable_seated_crunch": "cable_seated_crunch", + "cocoons": "cocoons", + "crossbody_crunch": "crossbody_crunch", + "crunch__hands_overhead": "crunch__hands_overhead", + "crunch__legs_on_exercise_ball": "crunch__legs_on_exercise_ball", + "crunches": "crunches", + "dead_bug": "dead_bug", + "decline_crunch": "decline_crunch", + "decline_oblique_crunch": "decline_oblique_crunch", + "decline_reverse_crunch": "decline_reverse_crunch", + "double_kettlebell_windmill": "double_kettlebell_windmill", + "dumbbell_side_bend": "dumbbell_side_bend", + "elbow_to_knee": "elbow_to_knee", + "exercise_ball_crunch": "exercise_ball_crunch", + "exercise_ball_pullin": "exercise_ball_pullin", + "flat_bench_leg_pullin": "flat_bench_leg_pullin", + "flat_bench_lying_leg_raise": "flat_bench_lying_leg_raise", + "frog_situps": "frog_situps", + "gorilla_chincrunch": "gorilla_chincrunch", + "hanging_leg_raise": "hanging_leg_raise", + "hanging_pike": "hanging_pike", + "jackknife_situp": "jackknife_situp", + "janda_situp": "janda_situp", + "kettlebell_figure_8": "kettlebell_figure_8", + "kettlebell_pass_between_the_legs": "kettlebell_pass_between_the_legs", + "kettlebell_windmill": "kettlebell_windmill", + "kneehip_raise_on_parallel_bars": "kneehip_raise_on_parallel_bars", + "kneeling_cable_crunch_with_alternating_oblique_twists": "kneeling_cable_crunch_with_alternating_oblique_twists", + "landmine_180s": "landmine_180s", + "leg_pullin": "leg_pullin", + "lower_back_curl": "lower_back_curl", + "medicine_ball_full_twist": "medicine_ball_full_twist", + "oblique_crunches": "oblique_crunches", + "oblique_crunches__on_the_floor": "oblique_crunches__on_the_floor", + "onearm_highpulley_cable_side_bends": "onearm_highpulley_cable_side_bends", + "onearm_medicine_ball_slam": "onearm_medicine_ball_slam", + "otisup": "otisup", + "overhead_stretch": "overhead_stretch", + "pallof_press": "pallof_press", + "pallof_press_with_rotation": "pallof_press_with_rotation", + "plank": "plank", + "plate_twist": "plate_twist", + "press_situp": "press_situp", + "reverse_crunch": "reverse_crunch", + "rope_crunch": "rope_crunch", + "russian_twist": "russian_twist", + "scissor_kick": "scissor_kick", + "seated_barbell_twist": "seated_barbell_twist", + "seated_flat_bench_leg_pullin": "seated_flat_bench_leg_pullin", + "seated_leg_tucks": "seated_leg_tucks", + "seated_overhead_stretch": "seated_overhead_stretch", + "side_bridge": "side_bridge", + "side_jackknife": "side_jackknife", + "situp": "situp", + "sledgehammer_swings": "sledgehammer_swings", + "smith_machine_hip_raise": "smith_machine_hip_raise", + "spell_caster": "spell_caster", + "spider_crawl": "spider_crawl", + "standing_cable_lift": "standing_cable_lift", + "standing_cable_wood_chop": "standing_cable_wood_chop", + "standing_lateral_stretch": "standing_lateral_stretch", + "standing_rope_crunch": "standing_rope_crunch", + "stomach_vacuum": "stomach_vacuum", + "supine_onearm_overhead_throw": "supine_onearm_overhead_throw", + "supine_twoarm_overhead_throw": "supine_twoarm_overhead_throw", + "suspended_fallout": "suspended_fallout", + "suspended_reverse_crunch": "suspended_reverse_crunch", + "toe_touchers": "toe_touchers", + "torso_rotation": "torso_rotation", + "tuck_crunch": "tuck_crunch", + "weighted_ball_side_bend": "weighted_ball_side_bend", + "weighted_crunches": "weighted_crunches", + "weighted_situps__with_bands": "weighted_situps__with_bands", + "wind_sprints": "wind_sprints", + "hip_circles_prone": "hip_circles_prone", + "it_band_and_glute_stretch": "it_band_and_glute_stretch", + "iliotibial_tractsmr": "iliotibial_tractsmr", + "lying_crossover": "lying_crossover", + "monster_walk": "monster_walk", + "standing_hip_circles": "standing_hip_circles", + "thigh_abductor": "thigh_abductor", + "windmills": "windmills", + "adductor": "adductor", + "adductorgroin": "adductorgroin", + "band_hip_adductions": "band_hip_adductions", + "carioca_quick_step": "carioca_quick_step", + "groin_and_back_stretch": "groin_and_back_stretch", + "groiners": "groiners", + "lateral_bound": "lateral_bound", + "lateral_box_jump": "lateral_box_jump", + "lateral_cone_hops": "lateral_cone_hops", + "lying_bent_leg_groin": "lying_bent_leg_groin", + "side_leg_raises": "side_leg_raises", + "side_lying_groin_stretch": "side_lying_groin_stretch", + "thigh_adductor": "thigh_adductor", + "alternate_hammer_curl": "alternate_hammer_curl", + "alternate_incline_dumbbell_curl": "alternate_incline_dumbbell_curl", + "barbell_curl": "barbell_curl", + "barbell_curls_lying_against_an_incline": "barbell_curls_lying_against_an_incline", + "brachialissmr": "brachialissmr", + "cable_hammer_curls__rope_attachment": "cable_hammer_curls__rope_attachment", + "cable_preacher_curl": "cable_preacher_curl", + "closegrip_ez_bar_curl": "closegrip_ez_bar_curl", + "closegrip_ezbar_curl_with_band": "closegrip_ezbar_curl_with_band", + "closegrip_standing_barbell_curl": "closegrip_standing_barbell_curl", + "concentration_curls": "concentration_curls", + "cross_body_hammer_curl": "cross_body_hammer_curl", + "drag_curl": "drag_curl", + "dumbbell_alternate_bicep_curl": "dumbbell_alternate_bicep_curl", + "dumbbell_bicep_curl": "dumbbell_bicep_curl", + "dumbbell_prone_incline_curl": "dumbbell_prone_incline_curl", + "ezbar_curl": "ezbar_curl", + "flexor_incline_dumbbell_curls": "flexor_incline_dumbbell_curls", + "hammer_curls": "hammer_curls", + "high_cable_curls": "high_cable_curls", + "incline_dumbbell_curl": "incline_dumbbell_curl", + "incline_hammer_curls": "incline_hammer_curls", + "incline_inner_biceps_curl": "incline_inner_biceps_curl", + "lying_cable_curl": "lying_cable_curl", + "lying_closegrip_bar_curl_on_high_pulley": "lying_closegrip_bar_curl_on_high_pulley", + "lying_high_bench_barbell_curl": "lying_high_bench_barbell_curl", + "lying_supine_dumbbell_curl": "lying_supine_dumbbell_curl", + "machine_bicep_curl": "machine_bicep_curl", + "machine_preacher_curls": "machine_preacher_curls", + "one_arm_dumbbell_preacher_curl": "one_arm_dumbbell_preacher_curl", + "overhead_cable_curl": "overhead_cable_curl", + "preacher_curl": "preacher_curl", + "preacher_hammer_dumbbell_curl": "preacher_hammer_dumbbell_curl", + "reverse_barbell_curl": "reverse_barbell_curl", + "reverse_barbell_preacher_curls": "reverse_barbell_preacher_curls", + "reverse_cable_curl": "reverse_cable_curl", + "reverse_plate_curls": "reverse_plate_curls", + "seated_biceps": "seated_biceps", + "seated_closegrip_concentration_barbell_curl": "seated_closegrip_concentration_barbell_curl", + "seated_dumbbell_curl": "seated_dumbbell_curl", + "seated_dumbbell_inner_biceps_curl": "seated_dumbbell_inner_biceps_curl", + "spider_curl": "spider_curl", + "standing_biceps_cable_curl": "standing_biceps_cable_curl", + "standing_biceps_stretch": "standing_biceps_stretch", + "standing_concentration_curl": "standing_concentration_curl", + "standing_dumbbell_reverse_curl": "standing_dumbbell_reverse_curl", + "standing_innerbiceps_curl": "standing_innerbiceps_curl", + "standing_onearm_cable_curl": "standing_onearm_cable_curl", + "standing_onearm_dumbbell_curl_over_incline_bench": "standing_onearm_dumbbell_curl_over_incline_bench", + "twoarm_dumbbell_preacher_curl": "twoarm_dumbbell_preacher_curl", + "widegrip_standing_barbell_curl": "widegrip_standing_barbell_curl", + "zottman_curl": "zottman_curl", + "zottman_preacher_curl": "zottman_preacher_curl", + "ankle_circles": "ankle_circles", + "anterior_tibialissmr": "anterior_tibialissmr", + "balance_board": "balance_board", + "barbell_seated_calf_raise": "barbell_seated_calf_raise", + "calf_press": "calf_press", + "calf_press_on_the_leg_press_machine": "calf_press_on_the_leg_press_machine", + "calf_raise_on_a_dumbbell": "calf_raise_on_a_dumbbell", + "calf_raises__with_bands": "calf_raises__with_bands", + "calf_stretch_elbows_against_wall": "calf_stretch_elbows_against_wall", + "calf_stretch_hands_against_wall": "calf_stretch_hands_against_wall", + "calvessmr": "calvessmr", + "donkey_calf_raises": "donkey_calf_raises", + "dumbbell_seated_oneleg_calf_raise": "dumbbell_seated_oneleg_calf_raise", + "footsmr": "footsmr", + "knee_circles": "knee_circles", + "peroneals_stretch": "peroneals_stretch", + "peronealssmr": "peronealssmr", + "posterior_tibialis_stretch": "posterior_tibialis_stretch", + "rocking_standing_calf_raise": "rocking_standing_calf_raise", + "seated_calf_raise": "seated_calf_raise", + "seated_calf_stretch": "seated_calf_stretch", + "smith_machine_calf_raise": "smith_machine_calf_raise", + "smith_machine_reverse_calf_raises": "smith_machine_reverse_calf_raises", + "standing_barbell_calf_raise": "standing_barbell_calf_raise", + "standing_calf_raises": "standing_calf_raises", + "standing_dumbbell_calf_raise": "standing_dumbbell_calf_raise", + "standing_gastrocnemius_calf_stretch": "standing_gastrocnemius_calf_stretch", + "standing_soleus_and_achilles_stretch": "standing_soleus_and_achilles_stretch", + "alternating_floor_press": "alternating_floor_press", + "around_the_worlds": "around_the_worlds", + "barbell_bench_press__medium_grip": "barbell_bench_press__medium_grip", + "barbell_guillotine_bench_press": "barbell_guillotine_bench_press", + "barbell_incline_bench_press__medium_grip": "barbell_incline_bench_press__medium_grip", + "behind_head_chest_stretch": "behind_head_chest_stretch", + "bench_press__with_bands": "bench_press__with_bands", + "bentarm_dumbbell_pullover": "bentarm_dumbbell_pullover", + "bodyweight_flyes": "bodyweight_flyes", + "butterfly": "butterfly", + "cable_chest_press": "cable_chest_press", + "cable_crossover": "cable_crossover", + "cable_iron_cross": "cable_iron_cross", + "chain_press": "chain_press", + "chest_and_front_of_shoulder_stretch": "chest_and_front_of_shoulder_stretch", + "chest_push_multiple_response": "chest_push_multiple_response", + "chest_push_single_response": "chest_push_single_response", + "chest_push_from_3_point_stance": "chest_push_from_3_point_stance", + "chest_push_with_run_release": "chest_push_with_run_release", + "chest_stretch_on_stability_ball": "chest_stretch_on_stability_ball", + "clock_pushup": "clock_pushup", + "cross_over__with_bands": "cross_over__with_bands", + "decline_barbell_bench_press": "decline_barbell_bench_press", + "decline_dumbbell_bench_press": "decline_dumbbell_bench_press", + "decline_dumbbell_flyes": "decline_dumbbell_flyes", + "decline_pushup": "decline_pushup", + "decline_smith_press": "decline_smith_press", + "dips__chest_version": "dips__chest_version", + "drop_push": "drop_push", + "dumbbell_bench_press": "dumbbell_bench_press", + "dumbbell_bench_press_with_neutral_grip": "dumbbell_bench_press_with_neutral_grip", + "dumbbell_flyes": "dumbbell_flyes", + "dynamic_chest_stretch": "dynamic_chest_stretch", + "elbows_back": "elbows_back", + "extended_range_onearm_kettlebell_floor_press": "extended_range_onearm_kettlebell_floor_press", + "flat_bench_cable_flyes": "flat_bench_cable_flyes", + "forward_drag_with_press": "forward_drag_with_press", + "front_raise_and_pullover": "front_raise_and_pullover", + "hammer_grip_incline_db_bench_press": "hammer_grip_incline_db_bench_press", + "heavy_bag_thrust": "heavy_bag_thrust", + "incline_cable_chest_press": "incline_cable_chest_press", + "incline_cable_flye": "incline_cable_flye", + "incline_dumbbell_bench_with_palms_facing_in": "incline_dumbbell_bench_with_palms_facing_in", + "incline_dumbbell_flyes": "incline_dumbbell_flyes", + "incline_dumbbell_flyes__with_a_twist": "incline_dumbbell_flyes__with_a_twist", + "incline_dumbbell_press": "incline_dumbbell_press", + "incline_pushup": "incline_pushup", + "incline_pushup_depth_jump": "incline_pushup_depth_jump", + "incline_pushup_medium": "incline_pushup_medium", + "incline_pushup_reverse_grip": "incline_pushup_reverse_grip", + "incline_pushup_wide": "incline_pushup_wide", + "isometric_chest_squeezes": "isometric_chest_squeezes", + "isometric_wipers": "isometric_wipers", + "legover_floor_press": "legover_floor_press", + "leverage_chest_press": "leverage_chest_press", + "leverage_decline_chest_press": "leverage_decline_chest_press", + "leverage_incline_chest_press": "leverage_incline_chest_press", + "low_cable_crossover": "low_cable_crossover", + "machine_bench_press": "machine_bench_press", + "medicine_ball_chest_pass": "medicine_ball_chest_pass", + "neck_press": "neck_press", + "one_arm_dumbbell_bench_press": "one_arm_dumbbell_bench_press", + "onearm_flat_bench_dumbbell_flye": "onearm_flat_bench_dumbbell_flye", + "onearm_kettlebell_floor_press": "onearm_kettlebell_floor_press", + "plyo_kettlebell_pushups": "plyo_kettlebell_pushups", + "plyo_pushup": "plyo_pushup", + "push_up_to_side_plank": "push_up_to_side_plank", + "pushup_wide": "pushup_wide", + "pushups_with_feet_elevated": "pushups_with_feet_elevated", + "pushups_with_feet_on_an_exercise_ball": "pushups_with_feet_on_an_exercise_ball", + "pushups": "pushups", + "pushups_close_and_wide_hand_positions": "pushups_close_and_wide_hand_positions", + "singlearm_cable_crossover": "singlearm_cable_crossover", + "singlearm_pushup": "singlearm_pushup", + "smith_machine_bench_press": "smith_machine_bench_press", + "smith_machine_decline_press": "smith_machine_decline_press", + "smith_machine_incline_bench_press": "longhaul_incline_bench_press_smith_machine", + "standing_cable_chest_press": "standing_cable_chest_press", + "straightarm_dumbbell_pullover": "straightarm_dumbbell_pullover", + "suspended_pushup": "suspended_pushup", + "svend_press": "svend_press", + "widegrip_barbell_bench_press": "widegrip_barbell_bench_press", + "widegrip_decline_barbell_bench_press": "widegrip_decline_barbell_bench_press", + "widegrip_decline_barbell_pullover": "widegrip_decline_barbell_pullover", + "bottomsup_clean_from_the_hang_position": "bottomsup_clean_from_the_hang_position", + "cable_wrist_curl": "cable_wrist_curl", + "dumbbell_lying_pronation": "dumbbell_lying_pronation", + "dumbbell_lying_supination": "dumbbell_lying_supination", + "farmers_walk": "farmers_walk", + "finger_curls": "finger_curls", + "kneeling_forearm_stretch": "kneeling_forearm_stretch", + "palmsdown_dumbbell_wrist_curl_over_a_bench": "palmsdown_dumbbell_wrist_curl_over_a_bench", + "palmsdown_wrist_curl_over_a_bench": "palmsdown_wrist_curl_over_a_bench", + "palmsup_barbell_wrist_curl_over_a_bench": "palmsup_barbell_wrist_curl_over_a_bench", + "palmsup_dumbbell_wrist_curl_over_a_bench": "palmsup_dumbbell_wrist_curl_over_a_bench", + "plate_pinch": "plate_pinch", + "rickshaw_carry": "rickshaw_carry", + "seated_dumbbell_palmsdown_wrist_curl": "seated_dumbbell_palmsdown_wrist_curl", + "seated_dumbbell_palmsup_wrist_curl": "seated_dumbbell_palmsup_wrist_curl", + "seated_onearm_dumbbell_palmsdown_wrist_curl": "seated_onearm_dumbbell_palmsdown_wrist_curl", + "seated_onearm_dumbbell_palmsup_wrist_curl": "seated_onearm_dumbbell_palmsup_wrist_curl", + "seated_palmup_barbell_wrist_curl": "seated_palmup_barbell_wrist_curl", + "seated_palmsdown_barbell_wrist_curl": "seated_palmsdown_barbell_wrist_curl", + "seated_twoarm_palmsup_lowpulley_wrist_curl": "seated_twoarm_palmsup_lowpulley_wrist_curl", + "standing_olympic_plate_hand_squeeze": "standing_olympic_plate_hand_squeeze", + "standing_palmsup_barbell_behind_the_back_wrist_curl": "standing_palmsup_barbell_behind_the_back_wrist_curl", + "wrist_circles": "wrist_circles", + "wrist_roller": "wrist_roller", + "wrist_rotations_with_straight_bar": "wrist_rotations_with_straight_bar", + "ankle_on_the_knee": "ankle_on_the_knee", + "barbell_glute_bridge": "barbell_glute_bridge", + "barbell_hip_thrust": "barbell_hip_thrust", + "butt_lift_bridge": "butt_lift_bridge", + "downward_facing_balance": "downward_facing_balance", + "flutter_kicks": "flutter_kicks", + "glute_kickback": "glute_kickback", + "hip_extension_with_bands": "hip_extension_with_bands", + "hip_lift_with_band": "hip_lift_with_band", + "knee_across_the_body": "knee_across_the_body", + "kneeling_jump_squat": "kneeling_jump_squat", + "kneeling_squat": "kneeling_squat", + "leg_lift": "leg_lift", + "lying_glute": "lying_glute", + "one_knee_to_chest": "one_knee_to_chest", + "onelegged_cable_kickback": "onelegged_cable_kickback", + "physioball_hip_bridge": "physioball_hip_bridge", + "piriformissmr": "piriformissmr", + "pull_through": "pull_through", + "seated_glute": "seated_glute", + "single_leg_glute_bridge": "single_leg_glute_bridge", + "stepup_with_knee_raise": "stepup_with_knee_raise", + "9090_hamstring": "9090_hamstring", + "alternating_hang_clean": "alternating_hang_clean", + "ball_leg_curl": "ball_leg_curl", + "band_good_morning": "band_good_morning", + "band_good_morning_pull_through": "band_good_morning_pull_through", + "box_jump_multiple_response": "box_jump_multiple_response", + "box_skip": "box_skip", + "chair_leg_extended_stretch": "chair_leg_extended_stretch", + "clean": "clean", + "clean_deadlift": "clean_deadlift", + "double_kettlebell_alternating_hang_clean": "double_kettlebell_alternating_hang_clean", + "dumbbell_clean": "dumbbell_clean", + "floor_gluteham_raise": "floor_gluteham_raise", + "front_box_jump": "front_box_jump", + "front_leg_raises": "front_leg_raises", + "glute_ham_raise": "glute_ham_raise", + "good_morning": "good_morning", + "good_morning_off_pins": "good_morning_off_pins", + "hamstring_stretch": "hamstring_stretch", + "hamstringsmr": "hamstringsmr", + "hang_snatch": "hang_snatch", + "hang_snatch__below_knees": "hang_snatch__below_knees", + "hanging_bar_good_morning": "hanging_bar_good_morning", + "hurdle_hops": "hurdle_hops", + "inchworm": "inchworm", + "intermediate_groin_stretch": "intermediate_groin_stretch", + "kettlebell_dead_clean": "kettlebell_dead_clean", + "kettlebell_hang_clean": "kettlebell_hang_clean", + "kettlebell_onelegged_deadlift": "kettlebell_onelegged_deadlift", + "knee_tuck_jump": "knee_tuck_jump", + "legup_hamstring_stretch": "legup_hamstring_stretch", + "linear_3part_start_technique": "linear_3part_start_technique", + "linear_acceleration_wall_drill": "linear_acceleration_wall_drill", + "lunge_pass_through": "lunge_pass_through", + "lying_hamstring": "lying_hamstring", + "lying_leg_curls": "lying_leg_curls", + "moving_claw_series": "moving_claw_series", + "muscle_snatch": "muscle_snatch", + "natural_glute_ham_raise": "natural_glute_ham_raise", + "onearm_kettlebell_clean": "onearm_kettlebell_clean", + "onearm_kettlebell_swings": "onearm_kettlebell_swings", + "onearm_open_palm_kettlebell_clean": "onearm_open_palm_kettlebell_clean", + "open_palm_kettlebell_clean": "open_palm_kettlebell_clean", + "platform_hamstring_slides": "platform_hamstring_slides", + "power_clean": "power_clean", + "power_clean_from_blocks": "power_clean_from_blocks", + "power_snatch": "power_snatch", + "power_stairs": "power_stairs", + "prone_manual_hamstring": "prone_manual_hamstring", + "prowler_sprint": "prowler_sprint", + "reverse_band_sumo_deadlift": "reverse_band_sumo_deadlift", + "reverse_hyperextension": "reverse_hyperextension", + "romanian_deadlift": "romanian_deadlift", + "romanian_deadlift_from_deficit": "romanian_deadlift_from_deficit", + "runners_stretch": "runners_stretch", + "seated_band_hamstring_curl": "seated_band_hamstring_curl", + "seated_floor_hamstring_stretch": "seated_floor_hamstring_stretch", + "seated_hamstring": "seated_hamstring", + "seated_hamstring_and_calf_stretch": "seated_hamstring_and_calf_stretch", + "seated_leg_curl": "seated_leg_curl", + "smith_machine_hang_power_clean": "smith_machine_hang_power_clean", + "smith_machine_stifflegged_deadlift": "smith_machine_stifflegged_deadlift", + "snatch_deadlift": "snatch_deadlift", + "snatch_pull": "snatch_pull", + "split_snatch": "split_snatch", + "split_squats": "split_squats", + "standing_hamstring_and_calf_stretch": "standing_hamstring_and_calf_stretch", + "standing_leg_curl": "standing_leg_curl", + "standing_toe_touches": "standing_toe_touches", + "stifflegged_barbell_deadlift": "stifflegged_barbell_deadlift", + "stifflegged_dumbbell_deadlift": "stifflegged_dumbbell_deadlift", + "sumo_deadlift": "sumo_deadlift", + "sumo_deadlift_with_bands": "sumo_deadlift_with_bands", + "sumo_deadlift_with_chains": "sumo_deadlift_with_chains", + "the_straddle": "the_straddle", + "upper_backleg_grab": "upper_backleg_grab", + "vertical_swing": "vertical_swing", + "wide_stance_stiff_legs": "wide_stance_stiff_legs", + "worlds_greatest_stretch": "worlds_greatest_stretch", + "band_assisted_pullup": "band_assisted_pullup", + "bentarm_barbell_pullover": "bentarm_barbell_pullover", + "cable_incline_pushdown": "cable_incline_pushdown", + "catch_and_overhead_throw": "catch_and_overhead_throw", + "chair_lower_back_stretch": "chair_lower_back_stretch", + "chinup": "chinup", + "closegrip_front_lat_pulldown": "closegrip_front_lat_pulldown", + "dynamic_back_stretch": "dynamic_back_stretch", + "elevated_cable_rows": "elevated_cable_rows", + "full_rangeofmotion_lat_pulldown": "full_rangeofmotion_lat_pulldown", + "gironda_sternum_chins": "gironda_sternum_chins", + "kipping_muscle_up": "kipping_muscle_up", + "kneeling_high_pulley_row": "kneeling_high_pulley_row", + "kneeling_singlearm_high_pulley_row": "kneeling_singlearm_high_pulley_row", + "latissimus_dorsismr": "latissimus_dorsismr", + "leverage_iso_row": "leverage_iso_row", + "london_bridges": "london_bridges", + "muscle_up": "muscle_up", + "one_arm_against_wall": "one_arm_against_wall", + "one_arm_lat_pulldown": "one_arm_lat_pulldown", + "one_handed_hang": "one_handed_hang", + "overhead_lat": "overhead_lat", + "overhead_slam": "overhead_slam", + "pullups": "pullups", + "rocky_pullupspulldowns": "rocky_pullupspulldowns", + "rope_climb": "rope_climb", + "rope_straightarm_pulldown": "rope_straightarm_pulldown", + "shotgun_row": "shotgun_row", + "side_to_side_chins": "side_to_side_chins", + "sidelying_floor_stretch": "sidelying_floor_stretch", + "straightarm_pulldown": "straightarm_pulldown", + "underhand_cable_pulldowns": "underhand_cable_pulldowns", + "vbar_pulldown": "vbar_pulldown", + "vbar_pullup": "vbar_pullup", + "weighted_pull_ups": "weighted_pull_ups", + "widegrip_lat_pulldown": "widegrip_lat_pulldown", + "widegrip_pulldown_behind_the_neck": "widegrip_pulldown_behind_the_neck", + "widegrip_rear_pullup": "widegrip_rear_pullup", + "atlas_stone_trainer": "atlas_stone_trainer", + "atlas_stones": "atlas_stones", + "axle_deadlift": "axle_deadlift", + "barbell_deadlift": "barbell_deadlift", + "cat_stretch": "cat_stretch", + "childs_pose": "childs_pose", + "crossover_reverse_lunge": "crossover_reverse_lunge", + "dancers_stretch": "dancers_stretch", + "deadlift_with_bands": "deadlift_with_bands", + "deadlift_with_chains": "deadlift_with_chains", + "deficit_deadlift": "deficit_deadlift", + "hug_a_ball": "hug_a_ball", + "hug_knees_to_chest": "hug_knees_to_chest", + "hyperextensions_back_extensions": "hyperextensions_back_extensions", + "hyperextensions_with_no_hyperextension_bench": "hyperextensions_with_no_hyperextension_bench", + "keg_load": "keg_load", + "lower_backsmr": "lower_backsmr", + "pelvic_tilt_into_bridge": "pelvic_tilt_into_bridge", + "pyramid": "pyramid", + "rack_pull_with_bands": "rack_pull_with_bands", + "rack_pulls": "rack_pulls", + "reverse_band_deadlift": "reverse_band_deadlift", + "seated_good_mornings": "seated_good_mornings", + "standing_pelvic_tilt": "standing_pelvic_tilt", + "stiff_leg_barbell_good_morning": "stiff_leg_barbell_good_morning", + "superman": "superman", + "weighted_ball_hyperextension": "weighted_ball_hyperextension", + "alternating_kettlebell_row": "alternating_kettlebell_row", + "alternating_renegade_row": "alternating_renegade_row", + "bent_over_barbell_row": "bent_over_barbell_row", + "bent_over_onearm_long_bar_row": "bent_over_onearm_long_bar_row", + "bent_over_twoarm_long_bar_row": "bent_over_twoarm_long_bar_row", + "bent_over_twodumbbell_row": "bent_over_twodumbbell_row", + "bent_over_twodumbbell_row_with_palms_in": "bent_over_twodumbbell_row_with_palms_in", + "bodyweight_mid_row": "bodyweight_mid_row", + "dumbbell_incline_row": "dumbbell_incline_row", + "incline_bench_pull": "incline_bench_pull", + "inverted_row": "inverted_row", + "inverted_row_with_straps": "inverted_row_with_straps", + "leverage_high_row": "leverage_high_row", + "lying_cambered_barbell_row": "lying_cambered_barbell_row", + "lying_tbar_row": "lying_tbar_row", + "middle_back_shrug": "middle_back_shrug", + "middle_back_stretch": "middle_back_stretch", + "mixed_grip_chin": "mixed_grip_chin", + "one_arm_chinup": "one_arm_chinup", + "onearm_dumbbell_row": "onearm_dumbbell_row", + "onearm_kettlebell_row": "onearm_kettlebell_row", + "onearm_long_bar_row": "onearm_long_bar_row", + "reverse_grip_bentover_rows": "reverse_grip_bentover_rows", + "rhomboidssmr": "rhomboidssmr", + "seated_cable_rows": "seated_cable_rows", + "seated_onearm_cable_pulley_rows": "seated_onearm_cable_pulley_rows", + "sled_row": "sled_row", + "smith_machine_bent_over_row": "smith_machine_bent_over_row", + "spinal_stretch": "spinal_stretch", + "straight_bar_bench_mid_rows": "straight_bar_bench_mid_rows", + "suspended_row": "suspended_row", + "tbar_row_with_handle": "tbar_row_with_handle", + "twoarm_kettlebell_row": "twoarm_kettlebell_row", + "upper_back_stretch": "upper_back_stretch", + "chin_to_chest_stretch": "chin_to_chest_stretch", + "isometric_neck_exercise__front_and_back": "isometric_neck_exercise__front_and_back", + "isometric_neck_exercise__sides": "isometric_neck_exercise__sides", + "lying_face_down_plate_neck_resistance": "lying_face_down_plate_neck_resistance", + "lying_face_up_plate_neck_resistance": "lying_face_up_plate_neck_resistance", + "necksmr": "necksmr", + "seated_head_harness_neck_resistance": "seated_head_harness_neck_resistance", + "side_neck_stretch": "side_neck_stretch", + "all_fours_quad_stretch": "all_fours_quad_stretch", + "alternate_leg_diagonal_bound": "alternate_leg_diagonal_bound", + "backward_drag": "backward_drag", + "barbell_full_squat": "barbell_full_squat", + "barbell_hack_squat": "longhaul_hack_squat_barbell", + "barbell_lunge": "barbell_lunge", + "barbell_side_split_squat": "barbell_side_split_squat", + "barbell_squat": "barbell_squat", + "barbell_squat_to_a_bench": "barbell_squat_to_a_bench", + "barbell_step_ups": "barbell_step_ups", + "barbell_walking_lunge": "barbell_walking_lunge", + "bear_crawl_sled_drags": "bear_crawl_sled_drags", + "bench_jump": "bench_jump", + "bench_sprint": "bench_sprint", + "bicycling": "bicycling", + "bicycling_stationary": "bicycling_stationary", + "bodyweight_squat": "bodyweight_squat", + "bodyweight_walking_lunge": "bodyweight_walking_lunge", + "box_squat": "box_squat", + "box_squat_with_bands": "box_squat_with_bands", + "box_squat_with_chains": "box_squat_with_chains", + "cable_deadlifts": "cable_deadlifts", + "cable_hip_adduction": "cable_hip_adduction", + "car_deadlift": "car_deadlift", + "chair_squat": "chair_squat", + "clean_pull": "clean_pull", + "clean_from_blocks": "clean_from_blocks", + "conans_wheel": "conans_wheel", + "depth_jump_leap": "depth_jump_leap", + "double_leg_butt_kick": "double_leg_butt_kick", + "dumbbell_lunges": "dumbbell_lunges", + "dumbbell_rear_lunge": "dumbbell_rear_lunge", + "dumbbell_seated_box_jump": "dumbbell_seated_box_jump", + "dumbbell_squat": "dumbbell_squat", + "dumbbell_squat_to_a_bench": "dumbbell_squat_to_a_bench", + "dumbbell_step_ups": "dumbbell_step_ups", + "elevated_back_lunge": "elevated_back_lunge", + "elliptical_trainer": "elliptical_trainer", + "fast_skipping": "fast_skipping", + "frankenstein_squat": "frankenstein_squat", + "freehand_jump_squat": "freehand_jump_squat", + "frog_hops": "frog_hops", + "front_barbell_squat": "front_barbell_squat", + "front_barbell_squat_to_a_bench": "front_barbell_squat_to_a_bench", + "front_cone_hops_or_hurdle_hops": "front_cone_hops_or_hurdle_hops", + "front_squat_clean_grip": "front_squat_clean_grip", + "front_squats_with_two_kettlebells": "front_squats_with_two_kettlebells", + "goblet_squat": "goblet_squat", + "hack_squat": "hack_squat", + "hang_clean": "hang_clean", + "hang_clean__below_the_knees": "hang_clean__below_the_knees", + "heaving_snatch_balance": "heaving_snatch_balance", + "hip_flexion_with_band": "hip_flexion_with_band", + "intermediate_hip_flexor_and_quad_stretch": "intermediate_hip_flexor_and_quad_stretch", + "iron_crosses_stretch": "iron_crosses_stretch", + "jefferson_squats": "jefferson_squats", + "jerk_dip_squat": "jerk_dip_squat", + "jogging_treadmill": "jogging_treadmill", + "kettlebell_pistol_squat": "kettlebell_pistol_squat", + "kneeling_hip_flexor": "kneeling_hip_flexor", + "leg_extensions": "leg_extensions", + "leg_press": "leg_press", + "leverage_deadlift": "leverage_deadlift", + "linear_depth_jump": "linear_depth_jump", + "looking_at_ceiling": "looking_at_ceiling", + "lunge_sprint": "lunge_sprint", + "lying_machine_squat": "lying_machine_squat", + "lying_prone_quadriceps": "lying_prone_quadriceps", + "mountain_climbers": "mountain_climbers", + "narrow_stance_hack_squats": "narrow_stance_hack_squats", + "narrow_stance_leg_press": "narrow_stance_leg_press", + "narrow_stance_squats": "narrow_stance_squats", + "olympic_squat": "olympic_squat", + "on_your_side_quad_stretch": "on_your_side_quad_stretch", + "onyourback_quad_stretch": "onyourback_quad_stretch", + "one_half_locust": "one_half_locust", + "one_leg_barbell_squat": "one_leg_barbell_squat", + "onearm_overhead_kettlebell_squats": "onearm_overhead_kettlebell_squats", + "onearm_side_deadlift": "onearm_side_deadlift", + "overhead_squat": "overhead_squat", + "plie_dumbbell_squat": "plie_dumbbell_squat", + "power_jerk": "power_jerk", + "power_snatch_from_blocks": "power_snatch_from_blocks", + "quad_stretch": "quad_stretch", + "quadricepssmr": "quadricepssmr", + "quick_leap": "quick_leap", + "rear_leg_raises": "rear_leg_raises", + "recumbent_bike": "recumbent_bike", + "reverse_band_box_squat": "reverse_band_box_squat", + "reverse_band_power_squat": "reverse_band_power_squat", + "rickshaw_deadlift": "rickshaw_deadlift", + "rocket_jump": "rocket_jump", + "rope_jumping": "rope_jumping", + "rowing_stationary": "rowing_stationary", + "running_treadmill": "running_treadmill", + "sandbag_load": "sandbag_load", + "scissors_jump": "scissors_jump", + "side_hopsprint": "side_hopsprint", + "side_standing_long_jump": "side_standing_long_jump", + "side_to_side_box_shuffle": "side_to_side_box_shuffle", + "single_leg_butt_kick": "single_leg_butt_kick", + "single_leg_pushoff": "single_leg_pushoff", + "singlecone_sprint_drill": "singlecone_sprint_drill", + "singleleg_high_box_squat": "singleleg_high_box_squat", + "singleleg_hop_progression": "singleleg_hop_progression", + "singleleg_lateral_hop": "singleleg_lateral_hop", + "singleleg_leg_extension": "singleleg_leg_extension", + "singleleg_stride_jump": "singleleg_stride_jump", + "sit_squats": "sit_squats", + "skating": "skating", + "sled_drag__harness": "sled_drag__harness", + "sled_push": "sled_push", + "smith_machine_leg_press": "smith_machine_leg_press", + "smith_machine_pistol_squat": "smith_machine_pistol_squat", + "smith_machine_squat": "smith_machine_squat", + "smith_singleleg_split_squat": "smith_singleleg_split_squat", + "snatch": "snatch", + "snatch_balance": "snatch_balance", + "snatch_from_blocks": "snatch_from_blocks", + "speed_box_squat": "speed_box_squat", + "speed_squats": "speed_squats", + "split_clean": "split_clean", + "split_jerk": "split_jerk", + "split_jump": "split_jump", + "split_squat_with_dumbbells": "split_squat_with_dumbbells", + "squat_jerk": "squat_jerk", + "squat_with_bands": "squat_with_bands", + "squat_with_chains": "squat_with_chains", + "squat_with_plate_movers": "squat_with_plate_movers", + "squats__with_bands": "squats__with_bands", + "stairmaster": "stairmaster", + "standing_elevated_quad_stretch": "standing_elevated_quad_stretch", + "standing_hip_flexors": "standing_hip_flexors", + "standing_long_jump": "standing_long_jump", + "star_jump": "star_jump", + "step_mill": "step_mill", + "stride_jump_crossover": "stride_jump_crossover", + "suspended_split_squat": "suspended_split_squat", + "tire_flip": "tire_flip", + "trail_runningwalking": "trail_runningwalking", + "trap_bar_deadlift": "trap_bar_deadlift", + "walking_treadmill": "walking_treadmill", + "weighted_jump_squat": "weighted_jump_squat", + "weighted_sissy_squat": "weighted_sissy_squat", + "weighted_squat": "weighted_squat", + "wide_stance_barbell_squat": "wide_stance_barbell_squat", + "yoke_walk": "yoke_walk", + "zercher_squats": "zercher_squats", + "alternating_cable_shoulder_press": "alternating_cable_shoulder_press", + "alternating_deltoid_raise": "alternating_deltoid_raise", + "alternating_kettlebell_press": "alternating_kettlebell_press", + "antigravity_press": "antigravity_press", + "arm_circles": "arm_circles", + "arnold_dumbbell_press": "arnold_dumbbell_press", + "back_flyes__with_bands": "back_flyes__with_bands", + "backward_medicine_ball_throw": "backward_medicine_ball_throw", + "band_pull_apart": "band_pull_apart", + "barbell_incline_shoulder_raise": "barbell_incline_shoulder_raise", + "barbell_rear_delt_row": "longhaul_rear_delt_row_barbell", + "barbell_shoulder_press": "barbell_shoulder_press", + "battling_ropes": "battling_ropes", + "bent_over_dumbbell_rear_delt_raise_with_head_on_bench": "bent_over_dumbbell_rear_delt_raise_with_head_on_bench", + "bent_over_lowpulley_side_lateral": "bent_over_lowpulley_side_lateral", + "bradfordrocky_presses": "bradfordrocky_presses", + "cable_internal_rotation": "cable_internal_rotation", + "cable_rear_delt_fly": "cable_rear_delt_fly", + "cable_rope_reardelt_rows": "cable_rope_reardelt_rows", + "cable_seated_lateral_raise": "cable_seated_lateral_raise", + "cable_shoulder_press": "cable_shoulder_press", + "car_drivers": "car_drivers", + "chair_upper_body_stretch": "chair_upper_body_stretch", + "circus_bell": "circus_bell", + "clean_and_jerk": "clean_and_jerk", + "clean_and_press": "clean_and_press", + "crucifix": "crucifix", + "cuban_press": "cuban_press", + "double_kettlebell_jerk": "double_kettlebell_jerk", + "double_kettlebell_push_press": "double_kettlebell_push_press", + "double_kettlebell_snatch": "double_kettlebell_snatch", + "dumbbell_incline_shoulder_raise": "dumbbell_incline_shoulder_raise", + "dumbbell_lying_onearm_rear_lateral_raise": "dumbbell_lying_onearm_rear_lateral_raise", + "dumbbell_lying_rear_lateral_raise": "dumbbell_lying_rear_lateral_raise", + "dumbbell_onearm_shoulder_press": "dumbbell_onearm_shoulder_press", + "dumbbell_onearm_upright_row": "dumbbell_onearm_upright_row", + "dumbbell_raise": "dumbbell_raise", + "dumbbell_scaption": "dumbbell_scaption", + "dumbbell_shoulder_press": "dumbbell_shoulder_press", + "elbow_circles": "elbow_circles", + "external_rotation": "external_rotation", + "external_rotation_with_band": "external_rotation_with_band", + "external_rotation_with_cable": "external_rotation_with_cable", + "face_pull": "face_pull", + "front_cable_raise": "front_cable_raise", + "front_dumbbell_raise": "front_dumbbell_raise", + "front_incline_dumbbell_raise": "front_incline_dumbbell_raise", + "front_plate_raise": "front_plate_raise", + "front_twodumbbell_raise": "front_twodumbbell_raise", + "handstand_pushups": "handstand_pushups", + "internal_rotation_with_band": "internal_rotation_with_band", + "iron_cross": "iron_cross", + "jerk_balance": "jerk_balance", + "kettlebell_arnold_press": "kettlebell_arnold_press", + "kettlebell_pirate_ships": "kettlebell_pirate_ships", + "kettlebell_seated_press": "kettlebell_seated_press", + "kettlebell_seesaw_press": "kettlebell_seesaw_press", + "kettlebell_thruster": "kettlebell_thruster", + "kettlebell_turkish_getup_lunge_style": "kettlebell_turkish_getup_lunge_style", + "kettlebell_turkish_getup_squat_style": "kettlebell_turkish_getup_squat_style", + "kneeling_arm_drill": "kneeling_arm_drill", + "landmine_linear_jammer": "landmine_linear_jammer", + "lateral_raise__with_bands": "lateral_raise__with_bands", + "leverage_shoulder_press": "leverage_shoulder_press", + "log_lift": "log_lift", + "low_pulley_row_to_neck": "low_pulley_row_to_neck", + "lying_onearm_lateral_raise": "lying_onearm_lateral_raise", + "lying_rear_delt_raise": "lying_rear_delt_raise", + "machine_shoulder_military_press": "machine_shoulder_military_press", + "medicine_ball_scoop_throw": "medicine_ball_scoop_throw", + "onearm_incline_lateral_raise": "onearm_incline_lateral_raise", + "onearm_kettlebell_clean_and_jerk": "onearm_kettlebell_clean_and_jerk", + "onearm_kettlebell_jerk": "onearm_kettlebell_jerk", + "onearm_kettlebell_military_press_to_the_side": "onearm_kettlebell_military_press_to_the_side", + "onearm_kettlebell_para_press": "onearm_kettlebell_para_press", + "onearm_kettlebell_push_press": "onearm_kettlebell_push_press", + "onearm_kettlebell_snatch": "onearm_kettlebell_snatch", + "onearm_kettlebell_split_jerk": "onearm_kettlebell_split_jerk", + "onearm_kettlebell_split_snatch": "onearm_kettlebell_split_snatch", + "onearm_side_laterals": "onearm_side_laterals", + "power_partials": "power_partials", + "push_press": "push_press", + "push_press__behind_the_neck": "push_press__behind_the_neck", + "rack_delivery": "rack_delivery", + "return_push_from_stance": "return_push_from_stance", + "reverse_flyes": "reverse_flyes", + "reverse_flyes_with_external_rotation": "reverse_flyes_with_external_rotation", + "reverse_machine_flyes": "reverse_machine_flyes", + "round_the_world_shoulder_stretch": "round_the_world_shoulder_stretch", + "seated_barbell_military_press": "seated_barbell_military_press", + "seated_bentover_rear_delt_raise": "seated_bentover_rear_delt_raise", + "seated_cable_shoulder_press": "seated_cable_shoulder_press", + "seated_dumbbell_press": "seated_dumbbell_press", + "seated_front_deltoid": "seated_front_deltoid", + "seated_side_lateral_raise": "seated_side_lateral_raise", + "seesaw_press_alternating_side_press": "seesaw_press_alternating_side_press", + "shoulder_circles": "shoulder_circles", + "shoulder_press__with_bands": "shoulder_press__with_bands", + "shoulder_raise": "shoulder_raise", + "shoulder_stretch": "shoulder_stretch", + "side_lateral_raise": "side_lateral_raise", + "side_laterals_to_front_raise": "side_laterals_to_front_raise", + "side_wrist_pull": "side_wrist_pull", + "single_dumbbell_raise": "single_dumbbell_raise", + "singlearm_linear_jammer": "singlearm_linear_jammer", + "sled_overhead_backward_walk": "sled_overhead_backward_walk", + "sled_reverse_flye": "sled_reverse_flye", + "smith_incline_shoulder_raise": "smith_incline_shoulder_raise", + "smith_machine_onearm_upright_row": "smith_machine_onearm_upright_row", + "smith_machine_overhead_shoulder_press": "smith_machine_overhead_shoulder_press", + "standing_alternating_dumbbell_press": "standing_alternating_dumbbell_press", + "standing_barbell_press_behind_neck": "standing_barbell_press_behind_neck", + "standing_bradford_press": "standing_bradford_press", + "standing_dumbbell_press": "standing_dumbbell_press", + "standing_dumbbell_straightarm_front_delt_raise_above_head": "standing_dumbbell_straightarm_front_delt_raise_above_head", + "standing_front_barbell_raise_over_head": "standing_front_barbell_raise_over_head", + "standing_lowpulley_deltoid_raise": "standing_lowpulley_deltoid_raise", + "standing_military_press": "standing_military_press", + "standing_palmin_onearm_dumbbell_press": "standing_palmin_onearm_dumbbell_press", + "standing_palmsin_dumbbell_press": "standing_palmsin_dumbbell_press", + "standing_twoarm_overhead_throw": "standing_twoarm_overhead_throw", + "straight_raises_on_incline_bench": "straight_raises_on_incline_bench", + "twoarm_kettlebell_clean": "twoarm_kettlebell_clean", + "twoarm_kettlebell_jerk": "twoarm_kettlebell_jerk", + "twoarm_kettlebell_military_press": "twoarm_kettlebell_military_press", + "upright_barbell_row": "upright_barbell_row", + "upward_stretch": "upward_stretch", + "barbell_shrug": "barbell_shrug", + "barbell_shrug_behind_the_back": "barbell_shrug_behind_the_back", + "cable_shrugs": "cable_shrugs", + "calfmachine_shoulder_shrug": "calfmachine_shoulder_shrug", + "clean_shrug": "clean_shrug", + "dumbbell_shrug": "dumbbell_shrug", + "kettlebell_sumo_high_pull": "kettlebell_sumo_high_pull", + "leverage_shrug": "leverage_shrug", + "scapular_pullup": "scapular_pullup", + "smith_machine_behind_the_back_shrug": "smith_machine_behind_the_back_shrug", + "smith_machine_upright_row": "smith_machine_upright_row", + "snatch_shrug": "snatch_shrug", + "standing_dumbbell_upright_row": "standing_dumbbell_upright_row", + "upright_cable_row": "upright_cable_row", + "upright_row__with_bands": "upright_row__with_bands", + "band_skull_crusher": "band_skull_crusher", + "bench_dips": "bench_dips", + "bench_press__powerlifting": "bench_press__powerlifting", + "bench_press_with_chains": "bench_press_with_chains", + "board_press": "board_press", + "body_tricep_press": "body_tricep_press", + "bodyup": "bodyup", + "cable_incline_triceps_extension": "cable_incline_triceps_extension", + "cable_lying_triceps_extension": "cable_lying_triceps_extension", + "cable_one_arm_tricep_extension": "cable_one_arm_tricep_extension", + "cable_rope_overhead_triceps_extension": "cable_rope_overhead_triceps_extension", + "chain_handle_extension": "chain_handle_extension", + "closegrip_barbell_bench_press": "closegrip_barbell_bench_press", + "closegrip_dumbbell_press": "closegrip_dumbbell_press", + "closegrip_ezbar_press": "closegrip_ezbar_press", + "closegrip_pushup_off_of_a_dumbbell": "closegrip_pushup_off_of_a_dumbbell", + "decline_closegrip_bench_to_skull_crusher": "decline_closegrip_bench_to_skull_crusher", + "decline_dumbbell_triceps_extension": "decline_dumbbell_triceps_extension", + "decline_ez_bar_triceps_extension": "decline_ez_bar_triceps_extension", + "dip_machine": "dip_machine", + "dips__triceps_version": "dips__triceps_version", + "dumbbell_floor_press": "dumbbell_floor_press", + "dumbbell_onearm_triceps_extension": "dumbbell_onearm_triceps_extension", + "dumbbell_tricep_extension_pronated_grip": "dumbbell_tricep_extension_pronated_grip", + "ezbar_skullcrusher": "ezbar_skullcrusher", + "floor_press": "floor_press", + "floor_press_with_chains": "floor_press_with_chains", + "incline_barbell_triceps_extension": "incline_barbell_triceps_extension", + "incline_pushup_closegrip": "incline_pushup_closegrip", + "jm_press": "jm_press", + "kneeling_cable_triceps_extension": "kneeling_cable_triceps_extension", + "low_cable_triceps_extension": "low_cable_triceps_extension", + "lying_closegrip_barbell_triceps_extension_behind_the_head": "lying_closegrip_barbell_triceps_extension_behind_the_head", + "lying_closegrip_barbell_triceps_press_to_chin": "lying_closegrip_barbell_triceps_press_to_chin", + "lying_dumbbell_tricep_extension": "lying_dumbbell_tricep_extension", + "lying_triceps_press": "lying_triceps_press", + "machine_triceps_extension": "machine_triceps_extension", + "one_arm_floor_press": "one_arm_floor_press", + "one_arm_pronated_dumbbell_triceps_extension": "one_arm_pronated_dumbbell_triceps_extension", + "one_arm_supinated_dumbbell_triceps_extension": "one_arm_supinated_dumbbell_triceps_extension", + "overhead_triceps": "overhead_triceps", + "parallel_bar_dip": "parallel_bar_dip", + "pin_presses": "pin_presses", + "pushups__close_triceps_position": "pushups__close_triceps_position", + "reverse_band_bench_press": "reverse_band_bench_press", + "reverse_grip_triceps_pushdown": "reverse_grip_triceps_pushdown", + "reverse_triceps_bench_press": "reverse_triceps_bench_press", + "ring_dips": "ring_dips", + "seated_bentover_onearm_dumbbell_triceps_extension": "seated_bentover_onearm_dumbbell_triceps_extension", + "seated_bentover_twoarm_dumbbell_triceps_extension": "seated_bentover_twoarm_dumbbell_triceps_extension", + "seated_triceps_press": "seated_triceps_press", + "sled_overhead_triceps_extension": "sled_overhead_triceps_extension", + "smith_machine_closegrip_bench_press": "smith_machine_closegrip_bench_press", + "speed_band_overhead_triceps": "speed_band_overhead_triceps", + "standing_bentover_onearm_dumbbell_triceps_extension": "standing_bentover_onearm_dumbbell_triceps_extension", + "standing_bentover_twoarm_dumbbell_triceps_extension": "standing_bentover_twoarm_dumbbell_triceps_extension", + "standing_dumbbell_triceps_extension": "standing_dumbbell_triceps_extension", + "standing_lowpulley_onearm_triceps_extension": "standing_lowpulley_onearm_triceps_extension", + "standing_onearm_dumbbell_triceps_extension": "standing_onearm_dumbbell_triceps_extension", + "standing_overhead_barbell_triceps_extension": "standing_overhead_barbell_triceps_extension", + "standing_towel_triceps_extension": "standing_towel_triceps_extension", + "supine_chest_throw": "supine_chest_throw", + "tate_press": "tate_press", + "tricep_dumbbell_kickback": "tricep_dumbbell_kickback", + "tricep_side_stretch": "tricep_side_stretch", + "triceps_overhead_extension_with_rope": "triceps_overhead_extension_with_rope", + "triceps_pushdown": "triceps_pushdown", + "triceps_pushdown__rope_attachment": "triceps_pushdown__rope_attachment", + "triceps_pushdown__vbar_attachment": "triceps_pushdown__vbar_attachment", + "triceps_stretch": "triceps_stretch", + "weighted_bench_dip": "weighted_bench_dip", + "longhaul_bench_press_smith_machine": "longhaul_bench_press_smith_machine", + "longhaul_calf_raise_leg_press_machine": "longhaul_calf_raise_leg_press_machine", + "longhaul_close_grip_feet_up_bench_press_barbell": "longhaul_close_grip_feet_up_bench_press_barbell", + "longhaul_deadlift_trap_bar_high_handles": "longhaul_deadlift_trap_bar_high_handles", + "longhaul_deadlift_trap_bar_low_handles": "longhaul_deadlift_trap_bar_low_handles", + "longhaul_external_shoulder_rotation_band": "longhaul_external_shoulder_rotation_band", + "longhaul_external_shoulder_rotation_cable": "longhaul_external_shoulder_rotation_cable", + "longhaul_glute_kickback_machine": "longhaul_glute_kickback_machine", + "longhaul_hack_squat_barbell": "longhaul_hack_squat_barbell", + "longhaul_hack_squat_landmine": "longhaul_hack_squat_landmine", + "longhaul_hack_squat_machine": "longhaul_hack_squat_machine", + "longhaul_high_to_low_wood_chop_cable": "longhaul_high_to_low_wood_chop_cable", + "longhaul_hip_abduction_band": "longhaul_hip_abduction_band", + "longhaul_hip_abduction_machine": "longhaul_hip_abduction_machine", + "longhaul_hip_adduction_machine": "longhaul_hip_adduction_machine", + "longhaul_horizontal_external_shoulder_rotation_dumbbell": "longhaul_horizontal_external_shoulder_rotation_dumbbell", + "longhaul_horizontal_internal_shoulder_rotation_dumbbell": "longhaul_horizontal_internal_shoulder_rotation_dumbbell", + "longhaul_horizontal_wood_chop_band": "longhaul_horizontal_wood_chop_band", + "longhaul_horizontal_wood_chop_cable": "longhaul_horizontal_wood_chop_cable", + "longhaul_incline_bench_press_smith_machine": "longhaul_incline_bench_press_smith_machine", + "longhaul_internal_shoulder_rotation_band": "longhaul_internal_shoulder_rotation_band", + "longhaul_internal_shoulder_rotation_cable": "longhaul_internal_shoulder_rotation_cable", + "longhaul_landmine_rotation": "longhaul_landmine_rotation", + "longhaul_leg_press_machine": "longhaul_leg_press_machine", + "longhaul_lying_external_shoulder_rotation_dumbbell": "longhaul_lying_external_shoulder_rotation_dumbbell", + "longhaul_lying_internal_shoulder_rotation_dumbbell": "longhaul_lying_internal_shoulder_rotation_dumbbell", + "longhaul_one_handed_shoulder_press_landmine": "longhaul_one_handed_shoulder_press_landmine", + "longhaul_pause_squat_barbell": "longhaul_pause_squat_barbell", + "longhaul_pull_apart_band": "band_pull_apart", + "longhaul_pullover_barbell": "longhaul_pullover_barbell", + "longhaul_pullover_dumbbell": "longhaul_pullover_dumbbell", + "longhaul_rear_delt_row_barbell": "longhaul_rear_delt_row_barbell", + "longhaul_rear_delt_row_cable": "longhaul_rear_delt_row_cable", + "longhaul_rear_delt_row_dumbbell": "longhaul_rear_delt_row_dumbbell", + "longhaul_row_t_bar": "longhaul_row_t_bar", + "longhaul_seated_shoulder_press_smith_machine": "longhaul_seated_shoulder_press_smith_machine", + "longhaul_shrug_trap_bar": "longhaul_shrug_trap_bar", + "longhaul_single_arm_half_kneeling_high_row_cable": "longhaul_single_arm_half_kneeling_high_row_cable", + "longhaul_single_leg_romanian_deadlift_landmine": "longhaul_single_leg_romanian_deadlift_landmine", + "longhaul_squat_belt": "longhaul_squat_belt", + "longhaul_squat_landmine": "longhaul_squat_landmine", + "longhaul_squat_smith_machine": "longhaul_squat_smith_machine", + "longhaul_stability_ball_pullover_dumbbell": "longhaul_stability_ball_pullover_dumbbell", + "longhaul_standing_external_shoulder_rotation_dumbbell": "longhaul_standing_external_shoulder_rotation_dumbbell", + "longhaul_standing_glute_kickback_machine": "longhaul_standing_glute_kickback_machine", + "longhaul_upright_row_barbell": "longhaul_upright_row_barbell" + }, + "duplicateResolutionLog": [ + { + "keptId": "ab_roller", + "removedIds": [], + "reason": "Merged true duplicate/name-format duplicate; aliases preserved on canonical record.", + "aliases": [], + "confidence": "high" + }, + { + "keptId": "air_bike", + "removedIds": [], + "reason": "Merged true duplicate/name-format duplicate; aliases preserved on canonical record.", + "aliases": [], + "confidence": "high" + }, + { + "keptId": "cable_shrugs", + "removedIds": [], + "reason": "Merged true duplicate/name-format duplicate; aliases preserved on canonical record.", + "aliases": [], + "confidence": "high" + }, + { + "keptId": "concentration_curls", + "removedIds": [], + "reason": "Merged true duplicate/name-format duplicate; aliases preserved on canonical record.", + "aliases": [], + "confidence": "high" + }, + { + "keptId": "dumbbell_step_ups", + "removedIds": [], + "reason": "Merged true duplicate/name-format duplicate; aliases preserved on canonical record.", + "aliases": [], + "confidence": "high" + }, + { + "keptId": "incline_hammer_curls", + "removedIds": [], + "reason": "Merged true duplicate/name-format duplicate; aliases preserved on canonical record.", + "aliases": [], + "confidence": "high" + }, + { + "keptId": "landmine_180s", + "removedIds": [], + "reason": "Merged true duplicate/name-format duplicate; aliases preserved on canonical record.", + "aliases": [], + "confidence": "high" + }, + { + "keptId": "machine_preacher_curls", + "removedIds": [], + "reason": "Merged true duplicate/name-format duplicate; aliases preserved on canonical record.", + "aliases": [], + "confidence": "high" + }, + { + "keptId": "overhead_press", + "removedIds": [], + "reason": "Merged true duplicate/name-format duplicate; aliases preserved on canonical record.", + "aliases": [], + "confidence": "high" + }, + { + "keptId": "mountain_climbers", + "removedIds": [], + "reason": "Merged true duplicate/name-format duplicate; aliases preserved on canonical record.", + "aliases": [], + "confidence": "high" + }, + { + "keptId": "plank_up_down", + "removedIds": [], + "reason": "Merged true duplicate/name-format duplicate; aliases preserved on canonical record.", + "aliases": [], + "confidence": "high" + }, + { + "keptId": "rack_pulls", + "removedIds": [], + "reason": "Merged true duplicate/name-format duplicate; aliases preserved on canonical record.", + "aliases": [], + "confidence": "high" + }, + { + "keptId": "seated_good_mornings", + "removedIds": [], + "reason": "Merged true duplicate/name-format duplicate; aliases preserved on canonical record.", + "aliases": [], + "confidence": "high" + }, + { + "keptId": "weighted_dip", + "removedIds": [], + "reason": "Merged true duplicate/name-format duplicate; aliases preserved on canonical record.", + "aliases": [], + "confidence": "high" + }, + { + "keptId": "zercher_squats", + "removedIds": [], + "reason": "Merged true duplicate/name-format duplicate; aliases preserved on canonical record.", + "aliases": [], + "confidence": "high" + } + ], + "breakingChanges": [ + { + "type": "tracking_type_changed", + "oldId": "ab_crunch_machine", + "newId": "ab_crunch_machine", + "exerciseName": "Ab Crunch Machine", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "ab_roller", + "newId": "ab_roller", + "exerciseName": "Ab Wheel Rollout", + "oldValue": "Ab Roller", + "newValue": "Ab Wheel Rollout", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "air_bike", + "newId": "air_bike", + "exerciseName": "Bicycle Crunch", + "oldValue": "Air Bike", + "newValue": "Bicycle Crunch", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "air_bike", + "newId": "air_bike", + "exerciseName": "Bicycle Crunch", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "barbell_ab_rollout__on_knees", + "newId": "barbell_ab_rollout__on_knees", + "exerciseName": "Barbell Ab Rollout - on Knees", + "oldValue": "Barbell Ab Rollout - On Knees", + "newValue": "Barbell Ab Rollout - on Knees", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "barbell_rollout_from_bench", + "newId": "barbell_rollout_from_bench", + "exerciseName": "Barbell Rollout From Bench", + "oldValue": "Barbell Rollout from Bench", + "newValue": "Barbell Rollout From Bench", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "bosu_ball_cable_crunch_with_side_bends", + "newId": "bosu_ball_cable_crunch_with_side_bends", + "exerciseName": "Bosu Ball Cable Crunch With Side Bends", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "bottoms_up", + "newId": "bottoms_up", + "exerciseName": "Bottoms up", + "oldValue": "Bottoms Up", + "newValue": "Bottoms up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "cable_crunch", + "newId": "cable_crunch", + "exerciseName": "Cable Crunch", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "cable_reverse_crunch", + "newId": "cable_reverse_crunch", + "exerciseName": "Cable Reverse Crunch", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "cable_seated_crunch", + "newId": "cable_seated_crunch", + "exerciseName": "Cable Seated Crunch", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "crossbody_crunch", + "newId": "crossbody_crunch", + "exerciseName": "Cross-Body Crunch", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "crunch__hands_overhead", + "newId": "crunch__hands_overhead", + "exerciseName": "Crunch - Hands Overhead", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "crunch__legs_on_exercise_ball", + "newId": "crunch__legs_on_exercise_ball", + "exerciseName": "Crunch - Legs on Exercise Ball", + "oldValue": "Crunch - Legs On Exercise Ball", + "newValue": "Crunch - Legs on Exercise Ball", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "crunch__legs_on_exercise_ball", + "newId": "crunch__legs_on_exercise_ball", + "exerciseName": "Crunch - Legs on Exercise Ball", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "crunches", + "newId": "crunches", + "exerciseName": "Crunches", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "decline_crunch", + "newId": "decline_crunch", + "exerciseName": "Decline Crunch", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "decline_oblique_crunch", + "newId": "decline_oblique_crunch", + "exerciseName": "Decline Oblique Crunch", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "decline_reverse_crunch", + "newId": "decline_reverse_crunch", + "exerciseName": "Decline Reverse Crunch", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "exercise_ball_crunch", + "newId": "exercise_ball_crunch", + "exerciseName": "Exercise Ball Crunch", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "flat_bench_leg_pullin", + "newId": "flat_bench_leg_pullin", + "exerciseName": "Flat Bench Leg Pull-In", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "gorilla_chincrunch", + "newId": "gorilla_chincrunch", + "exerciseName": "Gorilla Chin/Crunch", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "jackknife_situp", + "newId": "jackknife_situp", + "exerciseName": "Jackknife Sit-up", + "oldValue": "Jackknife Sit-Up", + "newValue": "Jackknife Sit-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "janda_situp", + "newId": "janda_situp", + "exerciseName": "Janda Sit-up", + "oldValue": "Janda Sit-Up", + "newValue": "Janda Sit-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "kettlebell_pass_between_the_legs", + "newId": "kettlebell_pass_between_the_legs", + "exerciseName": "Kettlebell Pass Between the Legs", + "oldValue": "Kettlebell Pass Between The Legs", + "newValue": "Kettlebell Pass Between the Legs", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "kneehip_raise_on_parallel_bars", + "newId": "kneehip_raise_on_parallel_bars", + "exerciseName": "Knee/Hip Raise on Parallel Bars", + "oldValue": "Knee/Hip Raise On Parallel Bars", + "newValue": "Knee/Hip Raise on Parallel Bars", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "kneeling_cable_crunch_with_alternating_oblique_twists", + "newId": "kneeling_cable_crunch_with_alternating_oblique_twists", + "exerciseName": "Kneeling Cable Crunch With Alternating Oblique Twists", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "landmine_180s", + "newId": "landmine_180s", + "exerciseName": "Landmine 180", + "oldValue": "Landmine 180's", + "newValue": "Landmine 180", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "oblique_crunches", + "newId": "oblique_crunches", + "exerciseName": "Oblique Crunches", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "oblique_crunches__on_the_floor", + "newId": "oblique_crunches__on_the_floor", + "exerciseName": "Oblique Crunches - on the Floor", + "oldValue": "Oblique Crunches - On The Floor", + "newValue": "Oblique Crunches - on the Floor", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "oblique_crunches__on_the_floor", + "newId": "oblique_crunches__on_the_floor", + "exerciseName": "Oblique Crunches - on the Floor", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "otisup", + "newId": "otisup", + "exerciseName": "Otis-up", + "oldValue": "Otis-Up", + "newValue": "Otis-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "overhead_stretch", + "newId": "overhead_stretch", + "exerciseName": "Overhead Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "plank", + "newId": "plank", + "exerciseName": "Plank", + "oldValue": "duration", + "newValue": "weight_reps", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "press_situp", + "newId": "press_situp", + "exerciseName": "Press Sit-up", + "oldValue": "Press Sit-Up", + "newValue": "Press Sit-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "reverse_crunch", + "newId": "reverse_crunch", + "exerciseName": "Reverse Crunch", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "rope_crunch", + "newId": "rope_crunch", + "exerciseName": "Rope Crunch", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "seated_flat_bench_leg_pullin", + "newId": "seated_flat_bench_leg_pullin", + "exerciseName": "Seated Flat Bench Leg Pull-In", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "seated_overhead_stretch", + "newId": "seated_overhead_stretch", + "exerciseName": "Seated Overhead Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "side_bridge", + "newId": "side_bridge", + "exerciseName": "Side Bridge", + "oldValue": "duration", + "newValue": "weight_reps", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "situp", + "newId": "situp", + "exerciseName": "Sit-up", + "oldValue": "Sit-Up", + "newValue": "Sit-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "spider_crawl", + "newId": "spider_crawl", + "exerciseName": "Spider Crawl", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "standing_lateral_stretch", + "newId": "standing_lateral_stretch", + "exerciseName": "Standing Lateral Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "standing_rope_crunch", + "newId": "standing_rope_crunch", + "exerciseName": "Standing Rope Crunch", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "stomach_vacuum", + "newId": "stomach_vacuum", + "exerciseName": "Stomach Vacuum", + "oldValue": "duration", + "newValue": "weight_reps", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "suspended_reverse_crunch", + "newId": "suspended_reverse_crunch", + "exerciseName": "Suspended Reverse Crunch", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "tuck_crunch", + "newId": "tuck_crunch", + "exerciseName": "Tuck Crunch", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "weighted_crunches", + "newId": "weighted_crunches", + "exerciseName": "Weighted Crunches", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "wind_sprints", + "newId": "wind_sprints", + "exerciseName": "Wind Sprints", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "hip_circles_prone", + "newId": "hip_circles_prone", + "exerciseName": "Hip Circles (prone)", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "it_band_and_glute_stretch", + "newId": "it_band_and_glute_stretch", + "exerciseName": "IT Band and Glute Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "equipment_value_changed", + "oldId": "it_band_and_glute_stretch", + "newId": "it_band_and_glute_stretch", + "exerciseName": "IT Band and Glute Stretch", + "oldValue": "Bodyweight", + "newValue": "Band", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + }, + { + "type": "tracking_type_changed", + "oldId": "iliotibial_tractsmr", + "newId": "iliotibial_tractsmr", + "exerciseName": "Iliotibial Tract-SMR", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "monster_walk", + "newId": "monster_walk", + "exerciseName": "Monster Walk", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "standing_hip_circles", + "newId": "standing_hip_circles", + "exerciseName": "Standing Hip Circles", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "windmills", + "newId": "windmills", + "exerciseName": "Windmills", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "adductorgroin", + "newId": "adductorgroin", + "exerciseName": "Adductor/Groin", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "carioca_quick_step", + "newId": "carioca_quick_step", + "exerciseName": "Carioca Quick Step", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "groin_and_back_stretch", + "newId": "groin_and_back_stretch", + "exerciseName": "Groin and Back Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "lateral_bound", + "newId": "lateral_bound", + "exerciseName": "Lateral Bound", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "lateral_box_jump", + "newId": "lateral_box_jump", + "exerciseName": "Lateral Box Jump", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "lateral_cone_hops", + "newId": "lateral_cone_hops", + "exerciseName": "Lateral Cone Hops", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "lying_bent_leg_groin", + "newId": "lying_bent_leg_groin", + "exerciseName": "Lying Bent Leg Groin", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "side_lying_groin_stretch", + "newId": "side_lying_groin_stretch", + "exerciseName": "Side Lying Groin Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "brachialissmr", + "newId": "brachialissmr", + "exerciseName": "Brachialis-SMR", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "closegrip_ezbar_curl_with_band", + "newId": "closegrip_ezbar_curl_with_band", + "exerciseName": "Close-Grip EZ-Bar Curl With Band", + "oldValue": "Close-Grip EZ-Bar Curl with Band", + "newValue": "Close-Grip EZ-Bar Curl With Band", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "concentration_curls", + "newId": "concentration_curls", + "exerciseName": "Concentration Curl", + "oldValue": "Concentration Curls", + "newValue": "Concentration Curl", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "hammer_curls", + "newId": "hammer_curls", + "exerciseName": "Hammer Curl", + "oldValue": "Hammer Curls", + "newValue": "Hammer Curl", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "incline_hammer_curls", + "newId": "incline_hammer_curls", + "exerciseName": "Incline Hammer Curl", + "oldValue": "Incline Hammer Curls", + "newValue": "Incline Hammer Curl", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "lying_closegrip_bar_curl_on_high_pulley", + "newId": "lying_closegrip_bar_curl_on_high_pulley", + "exerciseName": "Lying Close-Grip Bar Curl on High Pulley", + "oldValue": "Lying Close-Grip Bar Curl On High Pulley", + "newValue": "Lying Close-Grip Bar Curl on High Pulley", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "machine_preacher_curls", + "newId": "machine_preacher_curls", + "exerciseName": "Machine Preacher Curl", + "oldValue": "Machine Preacher Curls", + "newValue": "Machine Preacher Curl", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "standing_biceps_stretch", + "newId": "standing_biceps_stretch", + "exerciseName": "Standing Biceps Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "ankle_circles", + "newId": "ankle_circles", + "exerciseName": "Ankle Circles", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "anterior_tibialissmr", + "newId": "anterior_tibialissmr", + "exerciseName": "Anterior Tibialis-SMR", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "calf_press_on_the_leg_press_machine", + "newId": "calf_press_on_the_leg_press_machine", + "exerciseName": "Calf Press on the Leg Press Machine", + "oldValue": "Calf Press On The Leg Press Machine", + "newValue": "Calf Press on the Leg Press Machine", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "calf_raise_on_a_dumbbell", + "newId": "calf_raise_on_a_dumbbell", + "exerciseName": "Calf Raise on A Dumbbell", + "oldValue": "Calf Raise On A Dumbbell", + "newValue": "Calf Raise on A Dumbbell", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "calf_stretch_elbows_against_wall", + "newId": "calf_stretch_elbows_against_wall", + "exerciseName": "Calf Stretch Elbows Against Wall", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "calf_stretch_hands_against_wall", + "newId": "calf_stretch_hands_against_wall", + "exerciseName": "Calf Stretch Hands Against Wall", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "calvessmr", + "newId": "calvessmr", + "exerciseName": "Calves-SMR", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "footsmr", + "newId": "footsmr", + "exerciseName": "Foot-SMR", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "knee_circles", + "newId": "knee_circles", + "exerciseName": "Knee Circles", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "peroneals_stretch", + "newId": "peroneals_stretch", + "exerciseName": "Peroneals Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "peronealssmr", + "newId": "peronealssmr", + "exerciseName": "Peroneals-SMR", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "posterior_tibialis_stretch", + "newId": "posterior_tibialis_stretch", + "exerciseName": "Posterior Tibialis Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "seated_calf_stretch", + "newId": "seated_calf_stretch", + "exerciseName": "Seated Calf Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "standing_gastrocnemius_calf_stretch", + "newId": "standing_gastrocnemius_calf_stretch", + "exerciseName": "Standing Gastrocnemius Calf Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "standing_soleus_and_achilles_stretch", + "newId": "standing_soleus_and_achilles_stretch", + "exerciseName": "Standing Soleus and Achilles Stretch", + "oldValue": "Standing Soleus And Achilles Stretch", + "newValue": "Standing Soleus and Achilles Stretch", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "standing_soleus_and_achilles_stretch", + "newId": "standing_soleus_and_achilles_stretch", + "exerciseName": "Standing Soleus and Achilles Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "around_the_worlds", + "newId": "around_the_worlds", + "exerciseName": "Around the Worlds", + "oldValue": "Around The Worlds", + "newValue": "Around the Worlds", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "behind_head_chest_stretch", + "newId": "behind_head_chest_stretch", + "exerciseName": "Behind Head Chest Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "chest_and_front_of_shoulder_stretch", + "newId": "chest_and_front_of_shoulder_stretch", + "exerciseName": "Chest and Front of Shoulder Stretch", + "oldValue": "Chest And Front Of Shoulder Stretch", + "newValue": "Chest and Front of Shoulder Stretch", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "chest_and_front_of_shoulder_stretch", + "newId": "chest_and_front_of_shoulder_stretch", + "exerciseName": "Chest and Front of Shoulder Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "chest_push_multiple_response", + "newId": "chest_push_multiple_response", + "exerciseName": "Chest Push (multiple Response)", + "oldValue": "Chest Push (multiple response)", + "newValue": "Chest Push (multiple Response)", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "chest_push_single_response", + "newId": "chest_push_single_response", + "exerciseName": "Chest Push (single Response)", + "oldValue": "Chest Push (single response)", + "newValue": "Chest Push (single Response)", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "chest_push_from_3_point_stance", + "newId": "chest_push_from_3_point_stance", + "exerciseName": "Chest Push From 3 Point Stance", + "oldValue": "Chest Push from 3 point stance", + "newValue": "Chest Push From 3 Point Stance", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "chest_push_with_run_release", + "newId": "chest_push_with_run_release", + "exerciseName": "Chest Push With Run Release", + "oldValue": "Chest Push with Run Release", + "newValue": "Chest Push With Run Release", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "chest_push_with_run_release", + "newId": "chest_push_with_run_release", + "exerciseName": "Chest Push With Run Release", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "chest_stretch_on_stability_ball", + "newId": "chest_stretch_on_stability_ball", + "exerciseName": "Chest Stretch on Stability Ball", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "clock_pushup", + "newId": "clock_pushup", + "exerciseName": "Clock Push-up", + "oldValue": "Clock Push-Up", + "newValue": "Clock Push-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "decline_pushup", + "newId": "decline_pushup", + "exerciseName": "Decline Push-up", + "oldValue": "Decline Push-Up", + "newValue": "Decline Push-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "equipment_value_changed", + "oldId": "drop_push", + "newId": "drop_push", + "exerciseName": "Drop Push", + "oldValue": "Bodyweight", + "newValue": "Conditioning", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + }, + { + "type": "id_renamed", + "oldId": "dumbbell_bench_press_with_neutral_grip", + "newId": "dumbbell_bench_press_with_neutral_grip", + "exerciseName": "Dumbbell Bench Press With Neutral Grip", + "oldValue": "Dumbbell Bench Press with Neutral Grip", + "newValue": "Dumbbell Bench Press With Neutral Grip", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "dynamic_chest_stretch", + "newId": "dynamic_chest_stretch", + "exerciseName": "Dynamic Chest Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "elbows_back", + "newId": "elbows_back", + "exerciseName": "Elbows Back", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "forward_drag_with_press", + "newId": "forward_drag_with_press", + "exerciseName": "Forward Drag With Press", + "oldValue": "Forward Drag with Press", + "newValue": "Forward Drag With Press", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "front_raise_and_pullover", + "newId": "front_raise_and_pullover", + "exerciseName": "Front Raise and Pullover", + "oldValue": "Front Raise And Pullover", + "newValue": "Front Raise and Pullover", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "incline_pushup", + "newId": "incline_pushup", + "exerciseName": "Incline Push-up", + "oldValue": "Incline Push-Up", + "newValue": "Incline Push-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "incline_pushup_depth_jump", + "newId": "incline_pushup_depth_jump", + "exerciseName": "Incline Push-up Depth Jump", + "oldValue": "Incline Push-Up Depth Jump", + "newValue": "Incline Push-up Depth Jump", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "incline_pushup_medium", + "newId": "incline_pushup_medium", + "exerciseName": "Incline Push-up Medium", + "oldValue": "Incline Push-Up Medium", + "newValue": "Incline Push-up Medium", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "incline_pushup_reverse_grip", + "newId": "incline_pushup_reverse_grip", + "exerciseName": "Incline Push-up Reverse Grip", + "oldValue": "Incline Push-Up Reverse Grip", + "newValue": "Incline Push-up Reverse Grip", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "incline_pushup_wide", + "newId": "incline_pushup_wide", + "exerciseName": "Incline Push-up Wide", + "oldValue": "Incline Push-Up Wide", + "newValue": "Incline Push-up Wide", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "isometric_chest_squeezes", + "newId": "isometric_chest_squeezes", + "exerciseName": "Isometric Chest Squeezes", + "oldValue": "duration", + "newValue": "weight_reps", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "isometric_wipers", + "newId": "isometric_wipers", + "exerciseName": "Isometric Wipers", + "oldValue": "duration", + "newValue": "weight_reps", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "plyo_kettlebell_pushups", + "newId": "plyo_kettlebell_pushups", + "exerciseName": "Plyo Kettlebell Push-Ups", + "oldValue": "Plyo Kettlebell Pushups", + "newValue": "Plyo Kettlebell Push-Ups", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "push_up_to_side_plank", + "newId": "push_up_to_side_plank", + "exerciseName": "Push-up to Side Plank", + "oldValue": "Push Up to Side Plank", + "newValue": "Push-up to Side Plank", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "pushup_wide", + "newId": "pushup_wide", + "exerciseName": "Push-up Wide", + "oldValue": "Push-Up Wide", + "newValue": "Push-up Wide", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "pushups_with_feet_on_an_exercise_ball", + "newId": "pushups_with_feet_on_an_exercise_ball", + "exerciseName": "Push-Ups With Feet on An Exercise Ball", + "oldValue": "Push-Ups With Feet On An Exercise Ball", + "newValue": "Push-Ups With Feet on An Exercise Ball", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "pushups", + "newId": "pushups", + "exerciseName": "Push-up", + "oldValue": "Pushups", + "newValue": "Push-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "pushups_close_and_wide_hand_positions", + "newId": "pushups_close_and_wide_hand_positions", + "exerciseName": "Push-Ups (close and Wide Hand Positions)", + "oldValue": "Pushups (Close and Wide Hand Positions)", + "newValue": "Push-Ups (close and Wide Hand Positions)", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "singlearm_pushup", + "newId": "singlearm_pushup", + "exerciseName": "Single-Arm Push-up", + "oldValue": "Single-Arm Push-Up", + "newValue": "Single-Arm Push-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "smith_machine_incline_bench_press", + "newId": "longhaul_incline_bench_press_smith_machine", + "exerciseName": "Incline Bench Press - Smith Machine", + "oldValue": "smith_machine_incline_bench_press", + "newValue": "longhaul_incline_bench_press_smith_machine", + "impact": "workout_logs", + "mitigatedBy": "idMigrationMap", + "reason": "Old app ID maps to a canonical v2 exercise ID." + }, + { + "type": "id_renamed", + "oldId": "smith_machine_incline_bench_press", + "newId": "longhaul_incline_bench_press_smith_machine", + "exerciseName": "Incline Bench Press - Smith Machine", + "oldValue": "Smith Machine Incline Bench Press", + "newValue": "Incline Bench Press - Smith Machine", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "suspended_pushup", + "newId": "suspended_pushup", + "exerciseName": "Suspended Push-up", + "oldValue": "Suspended Push-Up", + "newValue": "Suspended Push-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "bottomsup_clean_from_the_hang_position", + "newId": "bottomsup_clean_from_the_hang_position", + "exerciseName": "Bottoms-up Clean From the Hang Position", + "oldValue": "Bottoms-Up Clean From The Hang Position", + "newValue": "Bottoms-up Clean From the Hang Position", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "kneeling_forearm_stretch", + "newId": "kneeling_forearm_stretch", + "exerciseName": "Kneeling Forearm Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "palmsdown_dumbbell_wrist_curl_over_a_bench", + "newId": "palmsdown_dumbbell_wrist_curl_over_a_bench", + "exerciseName": "Palms-down Dumbbell Wrist Curl Over A Bench", + "oldValue": "Palms-Down Dumbbell Wrist Curl Over A Bench", + "newValue": "Palms-down Dumbbell Wrist Curl Over A Bench", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "palmsdown_wrist_curl_over_a_bench", + "newId": "palmsdown_wrist_curl_over_a_bench", + "exerciseName": "Palms-down Wrist Curl Over A Bench", + "oldValue": "Palms-Down Wrist Curl Over A Bench", + "newValue": "Palms-down Wrist Curl Over A Bench", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "palmsup_barbell_wrist_curl_over_a_bench", + "newId": "palmsup_barbell_wrist_curl_over_a_bench", + "exerciseName": "Palms-up Barbell Wrist Curl Over A Bench", + "oldValue": "Palms-Up Barbell Wrist Curl Over A Bench", + "newValue": "Palms-up Barbell Wrist Curl Over A Bench", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "palmsup_dumbbell_wrist_curl_over_a_bench", + "newId": "palmsup_dumbbell_wrist_curl_over_a_bench", + "exerciseName": "Palms-up Dumbbell Wrist Curl Over A Bench", + "oldValue": "Palms-Up Dumbbell Wrist Curl Over A Bench", + "newValue": "Palms-up Dumbbell Wrist Curl Over A Bench", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "rickshaw_carry", + "newId": "rickshaw_carry", + "exerciseName": "Rickshaw Carry", + "oldValue": "duration_distance", + "newValue": "weight_reps", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "seated_dumbbell_palmsdown_wrist_curl", + "newId": "seated_dumbbell_palmsdown_wrist_curl", + "exerciseName": "Seated Dumbbell Palms-down Wrist Curl", + "oldValue": "Seated Dumbbell Palms-Down Wrist Curl", + "newValue": "Seated Dumbbell Palms-down Wrist Curl", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "seated_dumbbell_palmsup_wrist_curl", + "newId": "seated_dumbbell_palmsup_wrist_curl", + "exerciseName": "Seated Dumbbell Palms-up Wrist Curl", + "oldValue": "Seated Dumbbell Palms-Up Wrist Curl", + "newValue": "Seated Dumbbell Palms-up Wrist Curl", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "seated_onearm_dumbbell_palmsdown_wrist_curl", + "newId": "seated_onearm_dumbbell_palmsdown_wrist_curl", + "exerciseName": "Seated One-Arm Dumbbell Palms-down Wrist Curl", + "oldValue": "Seated One-Arm Dumbbell Palms-Down Wrist Curl", + "newValue": "Seated One-Arm Dumbbell Palms-down Wrist Curl", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "seated_onearm_dumbbell_palmsup_wrist_curl", + "newId": "seated_onearm_dumbbell_palmsup_wrist_curl", + "exerciseName": "Seated One-Arm Dumbbell Palms-up Wrist Curl", + "oldValue": "Seated One-Arm Dumbbell Palms-Up Wrist Curl", + "newValue": "Seated One-Arm Dumbbell Palms-up Wrist Curl", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "seated_palmup_barbell_wrist_curl", + "newId": "seated_palmup_barbell_wrist_curl", + "exerciseName": "Seated Palm-up Barbell Wrist Curl", + "oldValue": "Seated Palm-Up Barbell Wrist Curl", + "newValue": "Seated Palm-up Barbell Wrist Curl", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "seated_palmsdown_barbell_wrist_curl", + "newId": "seated_palmsdown_barbell_wrist_curl", + "exerciseName": "Seated Palms-down Barbell Wrist Curl", + "oldValue": "Seated Palms-Down Barbell Wrist Curl", + "newValue": "Seated Palms-down Barbell Wrist Curl", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "seated_twoarm_palmsup_lowpulley_wrist_curl", + "newId": "seated_twoarm_palmsup_lowpulley_wrist_curl", + "exerciseName": "Seated Two-Arm Palms-up Low-Pulley Wrist Curl", + "oldValue": "Seated Two-Arm Palms-Up Low-Pulley Wrist Curl", + "newValue": "Seated Two-Arm Palms-up Low-Pulley Wrist Curl", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "standing_palmsup_barbell_behind_the_back_wrist_curl", + "newId": "standing_palmsup_barbell_behind_the_back_wrist_curl", + "exerciseName": "Standing Palms-up Barbell Behind the Back Wrist Curl", + "oldValue": "Standing Palms-Up Barbell Behind The Back Wrist Curl", + "newValue": "Standing Palms-up Barbell Behind the Back Wrist Curl", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "wrist_circles", + "newId": "wrist_circles", + "exerciseName": "Wrist Circles", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "wrist_rotations_with_straight_bar", + "newId": "wrist_rotations_with_straight_bar", + "exerciseName": "Wrist Rotations With Straight Bar", + "oldValue": "Wrist Rotations with Straight Bar", + "newValue": "Wrist Rotations With Straight Bar", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "ankle_on_the_knee", + "newId": "ankle_on_the_knee", + "exerciseName": "Ankle on the Knee", + "oldValue": "Ankle On The Knee", + "newValue": "Ankle on the Knee", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "ankle_on_the_knee", + "newId": "ankle_on_the_knee", + "exerciseName": "Ankle on the Knee", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "butt_lift_bridge", + "newId": "butt_lift_bridge", + "exerciseName": "Butt Lift (bridge)", + "oldValue": "Butt Lift (Bridge)", + "newValue": "Butt Lift (bridge)", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "downward_facing_balance", + "newId": "downward_facing_balance", + "exerciseName": "Downward Facing Balance", + "oldValue": "duration", + "newValue": "weight_reps", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "glute_kickback", + "newId": "glute_kickback", + "exerciseName": "Glute Kickback", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "hip_extension_with_bands", + "newId": "hip_extension_with_bands", + "exerciseName": "Hip Extension With Bands", + "oldValue": "Hip Extension with Bands", + "newValue": "Hip Extension With Bands", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "hip_lift_with_band", + "newId": "hip_lift_with_band", + "exerciseName": "Hip Lift With Band", + "oldValue": "Hip Lift with Band", + "newValue": "Hip Lift With Band", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "knee_across_the_body", + "newId": "knee_across_the_body", + "exerciseName": "Knee Across the Body", + "oldValue": "Knee Across The Body", + "newValue": "Knee Across the Body", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "knee_across_the_body", + "newId": "knee_across_the_body", + "exerciseName": "Knee Across the Body", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "equipment_value_changed", + "oldId": "kneeling_squat", + "newId": "kneeling_squat", + "exerciseName": "Kneeling Squat", + "oldValue": "Bodyweight", + "newValue": "Barbell", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + }, + { + "type": "tracking_type_changed", + "oldId": "lying_glute", + "newId": "lying_glute", + "exerciseName": "Lying Glute", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "one_knee_to_chest", + "newId": "one_knee_to_chest", + "exerciseName": "One Knee to Chest", + "oldValue": "One Knee To Chest", + "newValue": "One Knee to Chest", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "one_knee_to_chest", + "newId": "one_knee_to_chest", + "exerciseName": "One Knee to Chest", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "piriformissmr", + "newId": "piriformissmr", + "exerciseName": "Piriformis-SMR", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "seated_glute", + "newId": "seated_glute", + "exerciseName": "Seated Glute", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "stepup_with_knee_raise", + "newId": "stepup_with_knee_raise", + "exerciseName": "Step-up With Knee Raise", + "oldValue": "Step-up with Knee Raise", + "newValue": "Step-up With Knee Raise", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "9090_hamstring", + "newId": "9090_hamstring", + "exerciseName": "90/90 Hamstring", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "band_good_morning_pull_through", + "newId": "band_good_morning_pull_through", + "exerciseName": "Band Good Morning (pull Through)", + "oldValue": "Band Good Morning (Pull Through)", + "newValue": "Band Good Morning (pull Through)", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "box_jump_multiple_response", + "newId": "box_jump_multiple_response", + "exerciseName": "Box Jump (multiple Response)", + "oldValue": "Box Jump (Multiple Response)", + "newValue": "Box Jump (multiple Response)", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "box_jump_multiple_response", + "newId": "box_jump_multiple_response", + "exerciseName": "Box Jump (multiple Response)", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "chair_leg_extended_stretch", + "newId": "chair_leg_extended_stretch", + "exerciseName": "Chair Leg Extended Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "front_box_jump", + "newId": "front_box_jump", + "exerciseName": "Front Box Jump", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "equipment_value_changed", + "oldId": "glute_ham_raise", + "newId": "glute_ham_raise", + "exerciseName": "Glute Ham Raise", + "oldValue": "Bodyweight", + "newValue": "Machine", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + }, + { + "type": "id_renamed", + "oldId": "good_morning_off_pins", + "newId": "good_morning_off_pins", + "exerciseName": "Good Morning Off Pins", + "oldValue": "Good Morning off Pins", + "newValue": "Good Morning Off Pins", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "hamstring_stretch", + "newId": "hamstring_stretch", + "exerciseName": "Hamstring Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "hamstringsmr", + "newId": "hamstringsmr", + "exerciseName": "Hamstring-SMR", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "hurdle_hops", + "newId": "hurdle_hops", + "exerciseName": "Hurdle Hops", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "intermediate_groin_stretch", + "newId": "intermediate_groin_stretch", + "exerciseName": "Intermediate Groin Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "legup_hamstring_stretch", + "newId": "legup_hamstring_stretch", + "exerciseName": "Leg-up Hamstring Stretch", + "oldValue": "Leg-Up Hamstring Stretch", + "newValue": "Leg-up Hamstring Stretch", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "legup_hamstring_stretch", + "newId": "legup_hamstring_stretch", + "exerciseName": "Leg-up Hamstring Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "lying_hamstring", + "newId": "lying_hamstring", + "exerciseName": "Lying Hamstring", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "power_clean_from_blocks", + "newId": "power_clean_from_blocks", + "exerciseName": "Power Clean From Blocks", + "oldValue": "Power Clean from Blocks", + "newValue": "Power Clean From Blocks", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "power_stairs", + "newId": "power_stairs", + "exerciseName": "Power Stairs", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "equipment_value_changed", + "oldId": "power_stairs", + "newId": "power_stairs", + "exerciseName": "Power Stairs", + "oldValue": "Bodyweight", + "newValue": "Conditioning", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + }, + { + "type": "tracking_type_changed", + "oldId": "prone_manual_hamstring", + "newId": "prone_manual_hamstring", + "exerciseName": "Prone Manual Hamstring", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "prowler_sprint", + "newId": "prowler_sprint", + "exerciseName": "Prowler Sprint", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "equipment_value_changed", + "oldId": "reverse_hyperextension", + "newId": "reverse_hyperextension", + "exerciseName": "Reverse Hyperextension", + "oldValue": "Bodyweight", + "newValue": "Machine", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + }, + { + "type": "id_renamed", + "oldId": "romanian_deadlift_from_deficit", + "newId": "romanian_deadlift_from_deficit", + "exerciseName": "Romanian Deadlift From Deficit", + "oldValue": "Romanian Deadlift from Deficit", + "newValue": "Romanian Deadlift From Deficit", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "runners_stretch", + "newId": "runners_stretch", + "exerciseName": "Runner's Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "seated_floor_hamstring_stretch", + "newId": "seated_floor_hamstring_stretch", + "exerciseName": "Seated Floor Hamstring Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "seated_hamstring", + "newId": "seated_hamstring", + "exerciseName": "Seated Hamstring", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "seated_hamstring_and_calf_stretch", + "newId": "seated_hamstring_and_calf_stretch", + "exerciseName": "Seated Hamstring and Calf Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "standing_hamstring_and_calf_stretch", + "newId": "standing_hamstring_and_calf_stretch", + "exerciseName": "Standing Hamstring and Calf Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "sumo_deadlift_with_bands", + "newId": "sumo_deadlift_with_bands", + "exerciseName": "Sumo Deadlift With Bands", + "oldValue": "Sumo Deadlift with Bands", + "newValue": "Sumo Deadlift With Bands", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "sumo_deadlift_with_chains", + "newId": "sumo_deadlift_with_chains", + "exerciseName": "Sumo Deadlift With Chains", + "oldValue": "Sumo Deadlift with Chains", + "newValue": "Sumo Deadlift With Chains", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "the_straddle", + "newId": "the_straddle", + "exerciseName": "the Straddle", + "oldValue": "The Straddle", + "newValue": "the Straddle", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "worlds_greatest_stretch", + "newId": "worlds_greatest_stretch", + "exerciseName": "World's Greatest Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "band_assisted_pullup", + "newId": "band_assisted_pullup", + "exerciseName": "Band Assisted Pull-up", + "oldValue": "Band Assisted Pull-Up", + "newValue": "Band Assisted Pull-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "equipment_value_changed", + "oldId": "band_assisted_pullup", + "newId": "band_assisted_pullup", + "exerciseName": "Band Assisted Pull-up", + "oldValue": "Bodyweight", + "newValue": "Band", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + }, + { + "type": "tracking_type_changed", + "oldId": "chair_lower_back_stretch", + "newId": "chair_lower_back_stretch", + "exerciseName": "Chair Lower Back Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "chinup", + "newId": "chinup", + "exerciseName": "Chin-up", + "oldValue": "Chin-Up", + "newValue": "Chin-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "dynamic_back_stretch", + "newId": "dynamic_back_stretch", + "exerciseName": "Dynamic Back Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "full_rangeofmotion_lat_pulldown", + "newId": "full_rangeofmotion_lat_pulldown", + "exerciseName": "Full Range-of-Motion Lat Pulldown", + "oldValue": "Full Range-Of-Motion Lat Pulldown", + "newValue": "Full Range-of-Motion Lat Pulldown", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "kipping_muscle_up", + "newId": "kipping_muscle_up", + "exerciseName": "Kipping Muscle up", + "oldValue": "Kipping Muscle Up", + "newValue": "Kipping Muscle up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "latissimus_dorsismr", + "newId": "latissimus_dorsismr", + "exerciseName": "Latissimus Dorsi-SMR", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "muscle_up", + "newId": "muscle_up", + "exerciseName": "Muscle up", + "oldValue": "Muscle Up", + "newValue": "Muscle up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "overhead_lat", + "newId": "overhead_lat", + "exerciseName": "Overhead Lat", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "pullups", + "newId": "pullups", + "exerciseName": "Pull-up", + "oldValue": "Pullups", + "newValue": "Pull-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "equipment_value_changed", + "oldId": "rope_climb", + "newId": "rope_climb", + "exerciseName": "Rope Climb", + "oldValue": "Bodyweight", + "newValue": "Conditioning", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + }, + { + "type": "id_renamed", + "oldId": "side_to_side_chins", + "newId": "side_to_side_chins", + "exerciseName": "Side to Side Chins", + "oldValue": "Side To Side Chins", + "newValue": "Side to Side Chins", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "sidelying_floor_stretch", + "newId": "sidelying_floor_stretch", + "exerciseName": "Side-Lying Floor Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "vbar_pullup", + "newId": "vbar_pullup", + "exerciseName": "V-Bar Pull-up", + "oldValue": "V-Bar Pullup", + "newValue": "V-Bar Pull-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "weighted_pull_ups", + "newId": "weighted_pull_ups", + "exerciseName": "Weighted Pull-up", + "oldValue": "Weighted Pull Ups", + "newValue": "Weighted Pull-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "widegrip_pulldown_behind_the_neck", + "newId": "widegrip_pulldown_behind_the_neck", + "exerciseName": "Wide-Grip Pulldown Behind the Neck", + "oldValue": "Wide-Grip Pulldown Behind The Neck", + "newValue": "Wide-Grip Pulldown Behind the Neck", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "widegrip_rear_pullup", + "newId": "widegrip_rear_pullup", + "exerciseName": "Wide-Grip Rear Pull-up", + "oldValue": "Wide-Grip Rear Pull-Up", + "newValue": "Wide-Grip Rear Pull-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "cat_stretch", + "newId": "cat_stretch", + "exerciseName": "Cat Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "childs_pose", + "newId": "childs_pose", + "exerciseName": "Child's Pose", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "dancers_stretch", + "newId": "dancers_stretch", + "exerciseName": "Dancer's Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "deadlift_with_bands", + "newId": "deadlift_with_bands", + "exerciseName": "Deadlift With Bands", + "oldValue": "Deadlift with Bands", + "newValue": "Deadlift With Bands", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "deadlift_with_chains", + "newId": "deadlift_with_chains", + "exerciseName": "Deadlift With Chains", + "oldValue": "Deadlift with Chains", + "newValue": "Deadlift With Chains", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "hug_a_ball", + "newId": "hug_a_ball", + "exerciseName": "Hug A Ball", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "hug_knees_to_chest", + "newId": "hug_knees_to_chest", + "exerciseName": "Hug Knees to Chest", + "oldValue": "Hug Knees To Chest", + "newValue": "Hug Knees to Chest", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "hug_knees_to_chest", + "newId": "hug_knees_to_chest", + "exerciseName": "Hug Knees to Chest", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "hyperextensions_back_extensions", + "newId": "hyperextensions_back_extensions", + "exerciseName": "Hyperextensions (back Extensions)", + "oldValue": "Hyperextensions (Back Extensions)", + "newValue": "Hyperextensions (back Extensions)", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "equipment_value_changed", + "oldId": "hyperextensions_back_extensions", + "newId": "hyperextensions_back_extensions", + "exerciseName": "Hyperextensions (back Extensions)", + "oldValue": "Bodyweight", + "newValue": "Machine", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + }, + { + "type": "tracking_type_changed", + "oldId": "lower_backsmr", + "newId": "lower_backsmr", + "exerciseName": "Lower Back-SMR", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "pyramid", + "newId": "pyramid", + "exerciseName": "Pyramid", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "rack_pull_with_bands", + "newId": "rack_pull_with_bands", + "exerciseName": "Rack Pull With Bands", + "oldValue": "Rack Pull with Bands", + "newValue": "Rack Pull With Bands", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "rack_pulls", + "newId": "rack_pulls", + "exerciseName": "Rack Pull", + "oldValue": "Rack Pulls", + "newValue": "Rack Pull", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "seated_good_mornings", + "newId": "seated_good_mornings", + "exerciseName": "Seated Good Morning", + "oldValue": "Seated Good Mornings", + "newValue": "Seated Good Morning", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "standing_pelvic_tilt", + "newId": "standing_pelvic_tilt", + "exerciseName": "Standing Pelvic Tilt", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "inverted_row_with_straps", + "newId": "inverted_row_with_straps", + "exerciseName": "Inverted Row With Straps", + "oldValue": "Inverted Row with Straps", + "newValue": "Inverted Row With Straps", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "middle_back_stretch", + "newId": "middle_back_stretch", + "exerciseName": "Middle Back Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "one_arm_chinup", + "newId": "one_arm_chinup", + "exerciseName": "One Arm Chin-up", + "oldValue": "One Arm Chin-Up", + "newValue": "One Arm Chin-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "rhomboidssmr", + "newId": "rhomboidssmr", + "exerciseName": "Rhomboids-SMR", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "seated_cable_rows", + "newId": "seated_cable_rows", + "exerciseName": "Seated Cable Row", + "oldValue": "Seated Cable Rows", + "newValue": "Seated Cable Row", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "seated_onearm_cable_pulley_rows", + "newId": "seated_onearm_cable_pulley_rows", + "exerciseName": "Seated One-Arm Cable Pulley Rows", + "oldValue": "Seated One-arm Cable Pulley Rows", + "newValue": "Seated One-Arm Cable Pulley Rows", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "spinal_stretch", + "newId": "spinal_stretch", + "exerciseName": "Spinal Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "tbar_row_with_handle", + "newId": "tbar_row_with_handle", + "exerciseName": "T-Bar Row With Handle", + "oldValue": "T-Bar Row with Handle", + "newValue": "T-Bar Row With Handle", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "upper_back_stretch", + "newId": "upper_back_stretch", + "exerciseName": "Upper Back Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "chin_to_chest_stretch", + "newId": "chin_to_chest_stretch", + "exerciseName": "Chin to Chest Stretch", + "oldValue": "Chin To Chest Stretch", + "newValue": "Chin to Chest Stretch", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "chin_to_chest_stretch", + "newId": "chin_to_chest_stretch", + "exerciseName": "Chin to Chest Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "isometric_neck_exercise__front_and_back", + "newId": "isometric_neck_exercise__front_and_back", + "exerciseName": "Isometric Neck Exercise - Front and Back", + "oldValue": "Isometric Neck Exercise - Front And Back", + "newValue": "Isometric Neck Exercise - Front and Back", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "isometric_neck_exercise__front_and_back", + "newId": "isometric_neck_exercise__front_and_back", + "exerciseName": "Isometric Neck Exercise - Front and Back", + "oldValue": "duration", + "newValue": "weight_reps", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "isometric_neck_exercise__sides", + "newId": "isometric_neck_exercise__sides", + "exerciseName": "Isometric Neck Exercise - Sides", + "oldValue": "duration", + "newValue": "weight_reps", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "lying_face_down_plate_neck_resistance", + "newId": "lying_face_down_plate_neck_resistance", + "exerciseName": "Lying Face down Plate Neck Resistance", + "oldValue": "Lying Face Down Plate Neck Resistance", + "newValue": "Lying Face down Plate Neck Resistance", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "lying_face_up_plate_neck_resistance", + "newId": "lying_face_up_plate_neck_resistance", + "exerciseName": "Lying Face up Plate Neck Resistance", + "oldValue": "Lying Face Up Plate Neck Resistance", + "newValue": "Lying Face up Plate Neck Resistance", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "necksmr", + "newId": "necksmr", + "exerciseName": "Neck-SMR", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "side_neck_stretch", + "newId": "side_neck_stretch", + "exerciseName": "Side Neck Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "all_fours_quad_stretch", + "newId": "all_fours_quad_stretch", + "exerciseName": "All Fours Quad Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "alternate_leg_diagonal_bound", + "newId": "alternate_leg_diagonal_bound", + "exerciseName": "Alternate Leg Diagonal Bound", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "backward_drag", + "newId": "backward_drag", + "exerciseName": "Backward Drag", + "oldValue": "duration_distance", + "newValue": "weight_reps", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "barbell_hack_squat", + "newId": "longhaul_hack_squat_barbell", + "exerciseName": "Hack Squat - Barbell", + "oldValue": "barbell_hack_squat", + "newValue": "longhaul_hack_squat_barbell", + "impact": "workout_logs", + "mitigatedBy": "idMigrationMap", + "reason": "Old app ID maps to a canonical v2 exercise ID." + }, + { + "type": "id_renamed", + "oldId": "barbell_hack_squat", + "newId": "longhaul_hack_squat_barbell", + "exerciseName": "Hack Squat - Barbell", + "oldValue": "Barbell Hack Squat", + "newValue": "Hack Squat - Barbell", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "barbell_squat_to_a_bench", + "newId": "barbell_squat_to_a_bench", + "exerciseName": "Barbell Squat to A Bench", + "oldValue": "Barbell Squat To A Bench", + "newValue": "Barbell Squat to A Bench", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "barbell_walking_lunge", + "newId": "barbell_walking_lunge", + "exerciseName": "Barbell Walking Lunge", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "bear_crawl_sled_drags", + "newId": "bear_crawl_sled_drags", + "exerciseName": "Bear Crawl Sled Drags", + "oldValue": "duration_distance", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "bench_sprint", + "newId": "bench_sprint", + "exerciseName": "Bench Sprint", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "bodyweight_walking_lunge", + "newId": "bodyweight_walking_lunge", + "exerciseName": "Bodyweight Walking Lunge", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "box_squat_with_bands", + "newId": "box_squat_with_bands", + "exerciseName": "Box Squat With Bands", + "oldValue": "Box Squat with Bands", + "newValue": "Box Squat With Bands", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "box_squat_with_chains", + "newId": "box_squat_with_chains", + "exerciseName": "Box Squat With Chains", + "oldValue": "Box Squat with Chains", + "newValue": "Box Squat With Chains", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "clean_from_blocks", + "newId": "clean_from_blocks", + "exerciseName": "Clean From Blocks", + "oldValue": "Clean from Blocks", + "newValue": "Clean From Blocks", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "conans_wheel", + "newId": "conans_wheel", + "exerciseName": "Conan's Wheel", + "oldValue": "duration_distance", + "newValue": "weight_reps", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "dumbbell_seated_box_jump", + "newId": "dumbbell_seated_box_jump", + "exerciseName": "Dumbbell Seated Box Jump", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "dumbbell_squat_to_a_bench", + "newId": "dumbbell_squat_to_a_bench", + "exerciseName": "Dumbbell Squat to A Bench", + "oldValue": "Dumbbell Squat To A Bench", + "newValue": "Dumbbell Squat to A Bench", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "dumbbell_step_ups", + "newId": "dumbbell_step_ups", + "exerciseName": "Dumbbell Step-Up", + "oldValue": "Dumbbell Step Ups", + "newValue": "Dumbbell Step-Up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "frog_hops", + "newId": "frog_hops", + "exerciseName": "Frog Hops", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "front_barbell_squat_to_a_bench", + "newId": "front_barbell_squat_to_a_bench", + "exerciseName": "Front Barbell Squat to A Bench", + "oldValue": "Front Barbell Squat To A Bench", + "newValue": "Front Barbell Squat to A Bench", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "front_cone_hops_or_hurdle_hops", + "newId": "front_cone_hops_or_hurdle_hops", + "exerciseName": "Front Cone Hops (or Hurdle Hops)", + "oldValue": "Front Cone Hops (or hurdle hops)", + "newValue": "Front Cone Hops (or Hurdle Hops)", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "front_cone_hops_or_hurdle_hops", + "newId": "front_cone_hops_or_hurdle_hops", + "exerciseName": "Front Cone Hops (or Hurdle Hops)", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "front_squat_clean_grip", + "newId": "front_squat_clean_grip", + "exerciseName": "Front Squat (clean Grip)", + "oldValue": "Front Squat (Clean Grip)", + "newValue": "Front Squat (clean Grip)", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "hip_flexion_with_band", + "newId": "hip_flexion_with_band", + "exerciseName": "Hip Flexion With Band", + "oldValue": "Hip Flexion with Band", + "newValue": "Hip Flexion With Band", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "intermediate_hip_flexor_and_quad_stretch", + "newId": "intermediate_hip_flexor_and_quad_stretch", + "exerciseName": "Intermediate Hip Flexor and Quad Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "iron_crosses_stretch", + "newId": "iron_crosses_stretch", + "exerciseName": "Iron Crosses (stretch)", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "leg_extensions", + "newId": "leg_extensions", + "exerciseName": "Leg Extension", + "oldValue": "Leg Extensions", + "newValue": "Leg Extension", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "looking_at_ceiling", + "newId": "looking_at_ceiling", + "exerciseName": "Looking At Ceiling", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "lunge_sprint", + "newId": "lunge_sprint", + "exerciseName": "Lunge Sprint", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "lying_prone_quadriceps", + "newId": "lying_prone_quadriceps", + "exerciseName": "Lying Prone Quadriceps", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "mountain_climbers", + "newId": "mountain_climbers", + "exerciseName": "Mountain Climber", + "oldValue": "Mountain Climbers", + "newValue": "Mountain Climber", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "on_your_side_quad_stretch", + "newId": "on_your_side_quad_stretch", + "exerciseName": "on Your Side Quad Stretch", + "oldValue": "On Your Side Quad Stretch", + "newValue": "on Your Side Quad Stretch", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "on_your_side_quad_stretch", + "newId": "on_your_side_quad_stretch", + "exerciseName": "on Your Side Quad Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "onyourback_quad_stretch", + "newId": "onyourback_quad_stretch", + "exerciseName": "on-Your-Back Quad Stretch", + "oldValue": "On-Your-Back Quad Stretch", + "newValue": "on-Your-Back Quad Stretch", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "onyourback_quad_stretch", + "newId": "onyourback_quad_stretch", + "exerciseName": "on-Your-Back Quad Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "power_snatch_from_blocks", + "newId": "power_snatch_from_blocks", + "exerciseName": "Power Snatch From Blocks", + "oldValue": "Power Snatch from Blocks", + "newValue": "Power Snatch From Blocks", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "quad_stretch", + "newId": "quad_stretch", + "exerciseName": "Quad Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "quadricepssmr", + "newId": "quadricepssmr", + "exerciseName": "Quadriceps-SMR", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "equipment_value_changed", + "oldId": "rope_jumping", + "newId": "rope_jumping", + "exerciseName": "Rope Jumping", + "oldValue": "Bodyweight", + "newValue": "Conditioning", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + }, + { + "type": "tracking_type_changed", + "oldId": "side_hopsprint", + "newId": "side_hopsprint", + "exerciseName": "Side Hop-Sprint", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "single_leg_pushoff", + "newId": "single_leg_pushoff", + "exerciseName": "Single Leg Push-Off", + "oldValue": "Single Leg Push-off", + "newValue": "Single Leg Push-Off", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "singlecone_sprint_drill", + "newId": "singlecone_sprint_drill", + "exerciseName": "Single-Cone Sprint Drill", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "equipment_value_changed", + "oldId": "singleleg_high_box_squat", + "newId": "singleleg_high_box_squat", + "exerciseName": "Single-Leg High Box Squat", + "oldValue": "Bodyweight", + "newValue": "Barbell", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + }, + { + "type": "tracking_type_changed", + "oldId": "singleleg_hop_progression", + "newId": "singleleg_hop_progression", + "exerciseName": "Single-Leg Hop Progression", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "singleleg_lateral_hop", + "newId": "singleleg_lateral_hop", + "exerciseName": "Single-Leg Lateral Hop", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "sled_drag__harness", + "newId": "sled_drag__harness", + "exerciseName": "Sled Drag - Harness", + "oldValue": "duration_distance", + "newValue": "weight_reps", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "sled_push", + "newId": "sled_push", + "exerciseName": "Sled Push", + "oldValue": "duration_distance", + "newValue": "weight_reps", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "snatch_from_blocks", + "newId": "snatch_from_blocks", + "exerciseName": "Snatch From Blocks", + "oldValue": "Snatch from Blocks", + "newValue": "Snatch From Blocks", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "split_squat_with_dumbbells", + "newId": "split_squat_with_dumbbells", + "exerciseName": "Split Squat With Dumbbells", + "oldValue": "Split Squat with Dumbbells", + "newValue": "Split Squat With Dumbbells", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "squat_with_bands", + "newId": "squat_with_bands", + "exerciseName": "Squat With Bands", + "oldValue": "Squat with Bands", + "newValue": "Squat With Bands", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "squat_with_chains", + "newId": "squat_with_chains", + "exerciseName": "Squat With Chains", + "oldValue": "Squat with Chains", + "newValue": "Squat With Chains", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "squat_with_plate_movers", + "newId": "squat_with_plate_movers", + "exerciseName": "Squat With Plate Movers", + "oldValue": "Squat with Plate Movers", + "newValue": "Squat With Plate Movers", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "standing_elevated_quad_stretch", + "newId": "standing_elevated_quad_stretch", + "exerciseName": "Standing Elevated Quad Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "equipment_value_changed", + "oldId": "suspended_split_squat", + "newId": "suspended_split_squat", + "exerciseName": "Suspended Split Squat", + "oldValue": "Bodyweight", + "newValue": "Barbell", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + }, + { + "type": "id_renamed", + "oldId": "zercher_squats", + "newId": "zercher_squats", + "exerciseName": "Zercher Squat", + "oldValue": "Zercher Squats", + "newValue": "Zercher Squat", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "arm_circles", + "newId": "arm_circles", + "exerciseName": "Arm Circles", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "barbell_rear_delt_row", + "newId": "longhaul_rear_delt_row_barbell", + "exerciseName": "Rear Delt Row - Barbell", + "oldValue": "barbell_rear_delt_row", + "newValue": "longhaul_rear_delt_row_barbell", + "impact": "workout_logs", + "mitigatedBy": "idMigrationMap", + "reason": "Old app ID maps to a canonical v2 exercise ID." + }, + { + "type": "id_renamed", + "oldId": "barbell_rear_delt_row", + "newId": "longhaul_rear_delt_row_barbell", + "exerciseName": "Rear Delt Row - Barbell", + "oldValue": "Barbell Rear Delt Row", + "newValue": "Rear Delt Row - Barbell", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "battling_ropes", + "newId": "battling_ropes", + "exerciseName": "Battling Ropes", + "oldValue": "duration_distance", + "newValue": "weight_reps", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "bent_over_dumbbell_rear_delt_raise_with_head_on_bench", + "newId": "bent_over_dumbbell_rear_delt_raise_with_head_on_bench", + "exerciseName": "Bent Over Dumbbell Rear Delt Raise With Head on Bench", + "oldValue": "Bent Over Dumbbell Rear Delt Raise With Head On Bench", + "newValue": "Bent Over Dumbbell Rear Delt Raise With Head on Bench", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "chair_upper_body_stretch", + "newId": "chair_upper_body_stretch", + "exerciseName": "Chair Upper Body Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "elbow_circles", + "newId": "elbow_circles", + "exerciseName": "Elbow Circles", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "equipment_value_changed", + "oldId": "external_rotation", + "newId": "external_rotation", + "exerciseName": "External Rotation", + "oldValue": "Bodyweight", + "newValue": "Band", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + }, + { + "type": "id_renamed", + "oldId": "external_rotation_with_band", + "newId": "external_rotation_with_band", + "exerciseName": "External Rotation With Band", + "oldValue": "External Rotation with Band", + "newValue": "External Rotation With Band", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "external_rotation_with_cable", + "newId": "external_rotation_with_cable", + "exerciseName": "External Rotation With Cable", + "oldValue": "External Rotation with Cable", + "newValue": "External Rotation With Cable", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "internal_rotation_with_band", + "newId": "internal_rotation_with_band", + "exerciseName": "Internal Rotation With Band", + "oldValue": "Internal Rotation with Band", + "newValue": "Internal Rotation With Band", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "kettlebell_turkish_getup_lunge_style", + "newId": "kettlebell_turkish_getup_lunge_style", + "exerciseName": "Kettlebell Turkish Get-up (lunge Style)", + "oldValue": "Kettlebell Turkish Get-Up (Lunge style)", + "newValue": "Kettlebell Turkish Get-up (lunge Style)", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "kettlebell_turkish_getup_squat_style", + "newId": "kettlebell_turkish_getup_squat_style", + "exerciseName": "Kettlebell Turkish Get-up (squat Style)", + "oldValue": "Kettlebell Turkish Get-Up (Squat style)", + "newValue": "Kettlebell Turkish Get-up (squat Style)", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "low_pulley_row_to_neck", + "newId": "low_pulley_row_to_neck", + "exerciseName": "Low Pulley Row to Neck", + "oldValue": "Low Pulley Row To Neck", + "newValue": "Low Pulley Row to Neck", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "machine_shoulder_military_press", + "newId": "machine_shoulder_military_press", + "exerciseName": "Machine Shoulder (military) Press", + "oldValue": "Machine Shoulder (Military) Press", + "newValue": "Machine Shoulder (military) Press", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "onearm_kettlebell_military_press_to_the_side", + "newId": "onearm_kettlebell_military_press_to_the_side", + "exerciseName": "One-Arm Kettlebell Military Press to the Side", + "oldValue": "One-Arm Kettlebell Military Press To The Side", + "newValue": "One-Arm Kettlebell Military Press to the Side", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "return_push_from_stance", + "newId": "return_push_from_stance", + "exerciseName": "Return Push From Stance", + "oldValue": "Return Push from Stance", + "newValue": "Return Push From Stance", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "round_the_world_shoulder_stretch", + "newId": "round_the_world_shoulder_stretch", + "exerciseName": "Round the World Shoulder Stretch", + "oldValue": "Round The World Shoulder Stretch", + "newValue": "Round the World Shoulder Stretch", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "round_the_world_shoulder_stretch", + "newId": "round_the_world_shoulder_stretch", + "exerciseName": "Round the World Shoulder Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "seesaw_press_alternating_side_press", + "newId": "seesaw_press_alternating_side_press", + "exerciseName": "See-Saw Press (alternating Side Press)", + "oldValue": "See-Saw Press (Alternating Side Press)", + "newValue": "See-Saw Press (alternating Side Press)", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "shoulder_circles", + "newId": "shoulder_circles", + "exerciseName": "Shoulder Circles", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "shoulder_stretch", + "newId": "shoulder_stretch", + "exerciseName": "Shoulder Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "side_wrist_pull", + "newId": "side_wrist_pull", + "exerciseName": "Side Wrist Pull", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "sled_overhead_backward_walk", + "newId": "sled_overhead_backward_walk", + "exerciseName": "Sled Overhead Backward Walk", + "oldValue": "weight_reps", + "newValue": "duration_distance", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "tracking_type_changed", + "oldId": "upward_stretch", + "newId": "upward_stretch", + "exerciseName": "Upward Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "barbell_shrug_behind_the_back", + "newId": "barbell_shrug_behind_the_back", + "exerciseName": "Barbell Shrug Behind the Back", + "oldValue": "Barbell Shrug Behind The Back", + "newValue": "Barbell Shrug Behind the Back", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "cable_shrugs", + "newId": "cable_shrugs", + "exerciseName": "Cable Shrug", + "oldValue": "Cable Shrugs", + "newValue": "Cable Shrug", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "scapular_pullup", + "newId": "scapular_pullup", + "exerciseName": "Scapular Pull-up", + "oldValue": "Scapular Pull-Up", + "newValue": "Scapular Pull-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "bench_press_with_chains", + "newId": "bench_press_with_chains", + "exerciseName": "Bench Press With Chains", + "oldValue": "Bench Press with Chains", + "newValue": "Bench Press With Chains", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "bodyup", + "newId": "bodyup", + "exerciseName": "Body-up", + "oldValue": "Body-Up", + "newValue": "Body-up", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "closegrip_pushup_off_of_a_dumbbell", + "newId": "closegrip_pushup_off_of_a_dumbbell", + "exerciseName": "Close-Grip Push-up Off of A Dumbbell", + "oldValue": "Close-Grip Push-Up off of a Dumbbell", + "newValue": "Close-Grip Push-up Off of A Dumbbell", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "equipment_value_changed", + "oldId": "closegrip_pushup_off_of_a_dumbbell", + "newId": "closegrip_pushup_off_of_a_dumbbell", + "exerciseName": "Close-Grip Push-up Off of A Dumbbell", + "oldValue": "Bodyweight", + "newValue": "Dumbbell", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + }, + { + "type": "id_renamed", + "oldId": "decline_closegrip_bench_to_skull_crusher", + "newId": "decline_closegrip_bench_to_skull_crusher", + "exerciseName": "Decline Close-Grip Bench to Skull Crusher", + "oldValue": "Decline Close-Grip Bench To Skull Crusher", + "newValue": "Decline Close-Grip Bench to Skull Crusher", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "floor_press_with_chains", + "newId": "floor_press_with_chains", + "exerciseName": "Floor Press With Chains", + "oldValue": "Floor Press with Chains", + "newValue": "Floor Press With Chains", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "incline_pushup_closegrip", + "newId": "incline_pushup_closegrip", + "exerciseName": "Incline Push-up Close-Grip", + "oldValue": "Incline Push-Up Close-Grip", + "newValue": "Incline Push-up Close-Grip", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "lying_closegrip_barbell_triceps_extension_behind_the_head", + "newId": "lying_closegrip_barbell_triceps_extension_behind_the_head", + "exerciseName": "Lying Close-Grip Barbell Triceps Extension Behind the Head", + "oldValue": "Lying Close-Grip Barbell Triceps Extension Behind The Head", + "newValue": "Lying Close-Grip Barbell Triceps Extension Behind the Head", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "id_renamed", + "oldId": "lying_closegrip_barbell_triceps_press_to_chin", + "newId": "lying_closegrip_barbell_triceps_press_to_chin", + "exerciseName": "Lying Close-Grip Barbell Triceps Press to Chin", + "oldValue": "Lying Close-Grip Barbell Triceps Press To Chin", + "newValue": "Lying Close-Grip Barbell Triceps Press to Chin", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "tricep_side_stretch", + "newId": "tricep_side_stretch", + "exerciseName": "Tricep Side Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "triceps_overhead_extension_with_rope", + "newId": "triceps_overhead_extension_with_rope", + "exerciseName": "Triceps Overhead Extension With Rope", + "oldValue": "Triceps Overhead Extension with Rope", + "newValue": "Triceps Overhead Extension With Rope", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "tracking_type_changed", + "oldId": "triceps_stretch", + "newId": "triceps_stretch", + "exerciseName": "Triceps Stretch", + "oldValue": "weight_reps", + "newValue": "duration", + "impact": "workout_logs", + "mitigatedBy": "none", + "reason": "v2 infers tracking from verified category/equipment/movement metadata." + }, + { + "type": "id_renamed", + "oldId": "longhaul_close_grip_feet_up_bench_press_barbell", + "newId": "longhaul_close_grip_feet_up_bench_press_barbell", + "exerciseName": "Close-Grip Feet-up Bench Press - Barbell", + "oldValue": "Close-Grip Feet-Up Bench Press - Barbell", + "newValue": "Close-Grip Feet-up Bench Press - Barbell", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "equipment_value_changed", + "oldId": "longhaul_hack_squat_landmine", + "newId": "longhaul_hack_squat_landmine", + "exerciseName": "Hack Squat - Landmine", + "oldValue": "Barbell", + "newValue": "Machine", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + }, + { + "type": "id_renamed", + "oldId": "longhaul_pull_apart_band", + "newId": "band_pull_apart", + "exerciseName": "Band Pull Apart", + "oldValue": "longhaul_pull_apart_band", + "newValue": "band_pull_apart", + "impact": "workout_logs", + "mitigatedBy": "idMigrationMap", + "reason": "Old app ID maps to a canonical v2 exercise ID." + }, + { + "type": "id_renamed", + "oldId": "longhaul_pull_apart_band", + "newId": "band_pull_apart", + "exerciseName": "Band Pull Apart", + "oldValue": "Pull-Apart - Band", + "newValue": "Band Pull Apart", + "impact": "search", + "mitigatedBy": "alias", + "reason": "Display name canonicalized; old name is retained as an alias where the record is present." + }, + { + "type": "equipment_value_changed", + "oldId": "longhaul_squat_belt", + "newId": "longhaul_squat_belt", + "exerciseName": "Squat - Belt", + "oldValue": "Other", + "newValue": "Barbell", + "impact": "filter_counts", + "mitigatedBy": "none", + "reason": "v2 separates simplified equipment from detailed equipment." + } + ], + "validationReport": { + "totalExercises": 1731, + "withPrimaryMuscle": 1731, + "withEquipment": 1731, + "withEquipmentDetail": 1731, + "withTrackingType": 1731, + "withIsBodyweight": 1731, + "withRequiresExternalLoad": 1731, + "withMovementPattern": 1731, + "withDifficulty": 1731, + "withAliases": 1731, + "withSecondaryMuscles": 1731, + "idCollisions": 0, + "nameCollisions": 0, + "invalidEquipmentValues": [], + "invalidEquipmentDetailValues": [], + "invalidTrackingTypeValues": [], + "invalidMuscleValues": [], + "invalidCategoryValues": [], + "invalidMovementPatternValues": [], + "invalidDifficultyValues": [], + "exercisesWithNullId": [], + "exercisesWithNullName": [], + "oldIdsNotInMigrationMap": [], + "allOldIdsAccountedFor": true, + "manualReviewRemaining": 0, + "manualReviewRemainingItems": [], + "sourceVerificationComplete": true + }, + "smokeTestSamples": [ + { + "id": "bench_press__powerlifting", + "name": "Bench Press - Powerlifting", + "primaryMuscle": "Triceps", + "primaryMuscles": [ + "Triceps" + ], + "secondaryMuscles": [ + "Chest", + "Shoulders", + "Abdominals" + ], + "equipment": "Barbell", + "equipmentDetail": "Barbell", + "apparatus": [ + "Barbell", + "Bench" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "push", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "pullups", + "name": "Pull-up", + "primaryMuscle": "Lats", + "primaryMuscles": [ + "Lats" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Middle back" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Pull-Up Bar", + "apparatus": [ + "Pull-Up Bar" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "pull", + "difficulty": "intermediate", + "aliases": [ + "Pullups" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "duplicate_candidate" + ] + }, + { + "id": "seated_cable_rows", + "name": "Seated Cable Row", + "primaryMuscle": "Middle back", + "primaryMuscles": [ + "Middle back" + ], + "secondaryMuscles": [ + "Biceps", + "Forearms", + "Shoulders", + "Abdominals", + "Lats", + "Traps" + ], + "equipment": "Cable", + "equipmentDetail": "Cable", + "apparatus": [ + "Cable Machine" + ], + "trackingType": "weight_reps", + "category": "strength", + "isBodyweight": false, + "requiresExternalLoad": true, + "movementPattern": "pull", + "difficulty": "beginner", + "aliases": [ + "Cable Row", + "Seated Cable Rows" + ], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "running_treadmill", + "name": "Running, Treadmill", + "primaryMuscle": "Quadriceps", + "primaryMuscles": [ + "Quadriceps" + ], + "secondaryMuscles": [ + "Glutes", + "Hamstrings", + "Adductors", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "locomotion", + "difficulty": "intermediate", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "worlds_greatest_stretch", + "name": "World's Greatest Stretch", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Lower back", + "Middle back", + "Abdominals" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration", + "category": "mobility", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "beginner", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + }, + { + "id": "air_bike", + "name": "Bicycle Crunch", + "primaryMuscle": "Abdominals", + "primaryMuscles": [ + "Abdominals" + ], + "secondaryMuscles": [], + "equipment": "Bodyweight", + "equipmentDetail": "Bodyweight", + "apparatus": [ + "Floor" + ], + "trackingType": "duration_distance", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "rotation", + "difficulty": "beginner", + "aliases": [ + "Air Bike", + "Supine Bicycle Crunch" + ], + "sourceTags": [ + "added_missing_exercise", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern", + "source_ace" + ] + }, + { + "id": "nordic_hamstring_curl", + "name": "Nordic Hamstring Curl", + "primaryMuscle": "Hamstrings", + "primaryMuscles": [ + "Hamstrings" + ], + "secondaryMuscles": [ + "Glutes", + "Calves" + ], + "equipment": "Bodyweight", + "equipmentDetail": "Rings", + "apparatus": [ + "Rings" + ], + "trackingType": "weight_reps", + "category": "bodyweight", + "isBodyweight": true, + "requiresExternalLoad": false, + "movementPattern": "hinge", + "difficulty": "advanced", + "aliases": [], + "sourceTags": [ + "existing_ironlog", + "taxonomy_verified_local", + "verified_equipment", + "verified_primary_muscle", + "verified_secondary_muscles", + "verified_movement_pattern" + ] + } + ] +} diff --git a/app/src/main/assets/ironlog/body_map_paths.json b/app/src/main/assets/ironlog/body_map_paths.json new file mode 100644 index 0000000..85be820 --- /dev/null +++ b/app/src/main/assets/ironlog/body_map_paths.json @@ -0,0 +1 @@ +{"VIEW_BOXES":{"male":{"front":{"x":-20,"y":95,"width":740,"height":1300},"back":{"x":740,"y":95,"width":680,"height":1320}}},"MUSCLE_MAP":{"chest":["chest","upperChest","lowerChest","serratus"],"back":["upperBack","lowerBack","trapezius","rhomboids"],"shoulders":["deltoids","frontDeltoid","rotatorCuff"],"rearDelts":["rearDeltoid"],"arms":["biceps","triceps","forearm","hands"],"core":["abs","obliques","upperAbs","lowerAbs"],"quads":["quadriceps","innerQuad","outerQuad","hipFlexors"],"hamstrings":["hamstring","adductors","gluteal"],"calves":["calves","tibialis","ankles","feet"],"head":["head","face","neck","hair"]},"MALE_FRONT_PATHS":{"chest":{"left":["M272.91 422.84c-18.95-17.19-22-57-12.64-78.79 5.57-12.99 26.54-24.37 39.97-25.87q20.36-2.26 37.02.75c9.74 1.76 16.13 15.64 18.41 25.04 3.99 16.48 3.23 31.38 1.67 48.06q-1.35 14.35-2.05 16.89c-6.52 23.5-38.08 29.23-58.28 24.53-9.12-2.12-17.24-4.38-24.1-10.61z"],"right":["M416.04 435c-15.12.11-34.46-6.78-41.37-21.48q-1.88-3.99-2.84-12.18c-2.89-24.41-5.9-53.65 8.44-74.79 4.26-6.26 10.49-7.93 18.36-8.56q11.66-.92 23.32-.35c10.58.53 18.02 2.74 26.62 7.87 12.81 7.65 19.73 14.52 22.67 29.75 4.94 25.57.24 64.14-28.21 74.97q-12.26 4.67-26.99 4.77z"]},"abs":{"left":["M311.02 531.71a.23.23 0 01-.19-.21q-.39-10.47 1.9-20.76c1.26-5.69 7.66-9.9 13.1-12.9 9.09-5.01 18.93-11.15 28.56-14.92a1.24 1.21-42.6 01.94.03c3.28 1.52 4.78 3.87 4.82 7.68q.13 13.16-.15 26.31c-.08 3.85.78 8.39-.87 13.1q-.17.46-.59.72-2.65 1.65-4.29 1.82-21.06 2.22-43.23-.87z","M321 577.76c-5.17-.33-8.71-.44-10-6.26q-3.2-14.44-.59-27.83.11-.53.64-.63c7.58-1.44 13.62-2.45 22.45-4.56q11.5-2.76 23.94-1.88c3.67.26 3.3 3.46 3.4 6.21q.46 12.55-.33 26.94-.25 4.41-1.81 8.08-.21.49-.73.6-1.39.28-3.22.29-16.89.14-33.75-.96z","M347.73 429.25c7.46-3.61 10.5 6.27 10.99 11.52.48 5.06 3.46 30.61-2.78 32.93q-4.17 1.55-6.89 3.33-17.56 11.54-35.88 21.46a1.6 1.59-21.9 01-2.3-.98c-2.87-10.41-10.59-43.96 1.66-50.95 11.3-6.45 23.96-11.86 35.2-17.31z","M350.35 712.81c-29.15-9.93-37.98-100.69-39.47-126.61a.99.99 0 01.33-.8c3.58-3.26 27.61-1.47 34.62-.93 4.41.34 15.27 1.31 15.26 7.53-.05 40.77.64 82.05-1.96 122.72a1.29 1.29 0 01-1.86 1.08c-2.3-1.14-4.12-2.04-6.92-2.99z"],"right":["M371.94 473.31c-5.46-2.59-2.97-24.26-2.77-29.56.25-6.8 2.41-18.63 12.64-13.8q16.26 7.67 32.34 15.72 6.18 3.1 7.13 10.05c.58 4.26 1.35 8.49 1.07 12.72q-.84 12.55-4.33 26.56-.54 2.16-1.1 3.44-.25.58-.81.31c-15.78-7.29-30.79-19.08-44.17-25.44z","M382.57 533.27c-4.17-.18-9.56-.3-13.15-2.69q-.17-.11-.24-.31c-1.82-5.55-.86-11.17-.96-15.66-.18-8.4-.78-17.36.06-25.71.29-2.85 1.88-4.42 4.15-5.79q.42-.26.91-.19 1.71.25 3.21 1.03 12.48 6.44 24.75 13.26c4.96 2.75 12.21 7.02 13.72 12.41q2.93 10.56 2.39 21.49a.77.76-1.8 01-.67.71q-16.89 2.18-34.17 1.45z","M373.75 578.69c-2.47 0-4.31.22-5-2.7-1.8-7.7-3.05-34.29-.19-38.81q.27-.43.77-.47 13.14-1.24 25.77 1.83c8.41 2.04 14.51 3.01 21.85 4.36a1.29 1.28.6 011.05 1.07q2.16 14.12-.73 28.07c-1.08 5.24-5.22 5.26-10.36 5.63q-14.26 1.04-33.16 1.02z","M416.32 584.73q1.14.41 1.07 1.62c-1.62 26.44-9.96 116.68-40.43 126.74-2.27.75-4.15 2.12-6.35 2.73q-1.18.33-1.3-.89-.86-9.2-1.06-17.75c-.83-35.67-.91-71.2-1.01-106.88q0-.5.31-.89c4.95-6.46 41.69-7.25 48.77-4.68z"]},"biceps":{"left":["M189.52 492.51c-2.43.62-7.38.57-7.51-3.08-.56-16.01-.42-35.49 5.11-50.26 3.19-8.54 13.89-30.22 23.27-32.72 10.08-2.68 12.68 16.59 12.6 22.8-.22 15.98-7.51 34.79-15.05 48.71-4.29 7.94-9.95 12.38-18.42 14.55z"],"right":["M526.69 486.31c-9.9-8.61-17.75-33.21-20.65-47.73-1.41-7.06-1.34-29.61 8.58-32.16 10.33-2.66 23.81 25.34 26.6 32.91q2.6 7.04 3.6 16.13 1.62 14.66 1.66 32.28c.03 11.04-16.45 1.48-19.79-1.43z"]},"triceps":{"left":["M206.2 514.2c-5.41-.67-6.55-7.29-4.69-11.42 11.08-24.55 22.84-50.62 30.54-75.51 1.37-4.41 3.08-8.59 3.95-12.45q2.94-13.12 5.79-26.26.42-1.98 1.82-3.39a.52.52 0 01.81.1q1.04 1.69 1.94 4.56 4.63 14.65 5.15 24.92c.57 11.36-5.11 24.55-8.65 35.5q-7.69 23.78-20.25 45.39c-2.45 4.23-11.51 19.18-16.41 18.56z"],"right":["M517.69 512.06c-20.07-22.12-28.95-51.73-38.01-79.03-3.27-9.87-3.58-19.18-1.34-29.38 1.29-5.88 2.49-13.03 5.61-18.52q.32-.57.72-.06 1.35 1.67 1.79 3.69c2.67 12.33 5.14 24.49 9.07 36.52 8.25 25.28 18.58 49.8 31.1 77.2q1.42 3.1 1.05 5.33c-.81 4.89-5.46 9.25-9.99 4.25z"]},"deltoids":{"left":["M274.06 311.69q3.94 2.77 4.33 8.14.04.48-.38.73c-9.98 5.88-24.35 7.45-28.82 19.75-2.31 6.36-.97 17.35-1.43 23.68q-.55 7.51-5.73 14.07-10.37 13.11-13.81 16.67c-3.41 3.53-6.81 1.76-10.69-.47-15.42-8.87-24.95-25.45-22.52-43.22 2.05-14.92 12.71-25.79 24.06-35.02 16.99-13.82 35.58-17.99 54.99-4.33z"],"right":["M450.39 320.75q-.95-.52-.7-1.58c1.57-6.61 5.8-9.1 12.14-11.9 24.99-11.03 43.76 3.33 60.17 20.74 20.73 21.99 11.81 56.44-14.82 68.19-4.41 1.94-6.79-1.03-9.81-4.51-5.81-6.7-13.46-14.12-15.99-22.8-3.93-13.43 4.32-27.54-9.64-37.62q-8.22-5.93-17.99-9.08-1.84-.59-3.36-1.44z"]},"obliques":{"left":["M264.21 435.53c-4.88-3.13-5.75-12.11-5.39-17.36q.03-.53.51-.75 1.8-.84 3.43.85 10.05 10.45 22.57 16.9c3.64 1.89 5.54 3.62 4.79 7.8q-.42 2.35-2.82 1.87-12.45-2.49-23.09-9.31z","M287.33 452.44c-4.05 4.46-10.38 11.38-16.28 14.3a.84.83 51.1 01-.9-.1c-6.29-5.17-12.54-18.97-14.21-25.09q-.91-3.34.85-8.81.12-.39.35-.05c2.41 3.65 4.59 7.74 8.67 9.76q10.18 5.05 21.27 9.01a.61.61 0 01.25.98z","M297.3 487.82c-7.36-4.23-16.68-11.37-20.55-17.57q-.32-.5.09-.92 8.72-9.04 19.84-17.87 1.46-1.17 2.81-1.67a.44.44 0 01.59.43c-.28 10.08-.4 20.42.65 30.43q.34 3.26-.68 6.15a1.9 1.9 0 01-2.75 1.02z","M257.35 456.18l13.68 16.63a1.86 1.82 22.9 01.4.95c.59 5.4-2.02 12.71-3.8 17.56q-.3.84-.84.13-11.85-15.55-9.77-35.17.04-.45.33-.1z","M271.69 494.07a1.53 1.52-61.8 01-.49-1.64l4.2-13.58a.98.98 0 011.51-.5c3.2 2.32 21.89 14.05 22.26 16.7q1.15 8.32.66 16.79a.9.9 0 01-1.34.73q-14.24-8.05-26.8-18.5z","M299.35 544.62c-7.52-6.03-16.15-13.43-24.23-21.24-6.93-6.7-6-17.19-4.88-26.06a.44.44 0 01.72-.28q13.31 11.88 28.41 21.38.43.27.6.75c2.33 6.49.95 18.37-.07 25.23q-.09.59-.55.22z","M299.09 575.53c-7.98-3.65-27.57-15.86-28.06-26.2q-.57-11.91.46-24.3a.36.36 0 01.67-.15q.84 1.36 2.17 2.54 10.59 9.45 21.68 18.31c4.37 3.49 4.34 6.46 4.16 11.74q-.3 8.82-.42 17.64-.01.72-.66.42z","M308.17 657.58c-7.39-.13-12.41-4.13-17.14-9.39q-11.86-13.22-23.92-26.37-.33-.36-.33-.85.09-23.18 1.81-46.22.53-7.13 2.49-14.41a.71.71 0 011.2-.3q11.54 12.06 25.82 21.1 3.36 2.12 3.62 5.17 2.06 23.67 3.86 47.36c.58 7.62 2.31 13.36 4.43 20.82q.47 1.66-.96 2.79-.39.31-.88.3z"],"right":["M438.7 444.36c-2.09-4.03-.13-6.83 3.63-8.81 10.22-5.36 16.79-11 24.23-18.07a1.71 1.71 0 012.89 1.12c.33 4.74-.81 14.39-5.53 17.22-4.68 2.82-18.74 10.02-24.39 9.14q-.57-.09-.83-.6z","M457.39 466.73c-3.72-1.02-13.2-10.29-16.5-14.49a.52.52 0 01.24-.81q10.94-3.75 21.31-9c3.96-2.01 6.3-5.98 8.57-9.58q.38-.59.55.09c.82 3.33 1.54 6.17.38 9.58-2.55 7.44-7.62 18.79-13.66 24.01a.96.96 0 01-.89.2z","M428.43 487.22c-1.01-1.79-.82-4.55-.71-6.72q.78-15.08.48-30.27-.01-.59.55-.4 1.72.59 3.02 1.64 11.58 9.37 18.82 16.95c3.86 4.05-16.2 17.42-19.56 19.48a1.87 1.86 59.6 01-2.6-.68z","M470.76 456.28a.25.25 0 01.44.13q2.03 19.67-9.8 35.22-.37.48-.6-.08c-1.37-3.29-5.86-16.13-3.51-18.91q6.3-7.47 13.47-16.36z","M452.27 478.5c1.13.49 4.28 12.47 4.78 14.38q.14.5-.23.88-1.29 1.35-2.65 2.41-10.44 8.12-21.76 14.97-1.49.9-2.91 1.33a.81.81 0 01-1.05-.71q-.73-8.62.67-17.15.08-.47.44-.8c1.74-1.6 21.96-15.73 22.34-15.51a.58.03 31 00.37.2z","M428.22 519.14q.11-.36.43-.56 15.3-9.66 28.83-21.69a.43.42-22.6 01.71.29c.51 8.26 2.25 18.67-4.46 25.4q-11.8 11.84-25.03 22.09-.43.34-.49-.2c-.75-6.82-1.97-18.92.01-25.33z","M456.54 524.55a.04.04 0 01.07.02q1.52 13.67.41 27.4-.04.47-.28.88c-4.97 8.3-18.23 19.62-27.88 22.63q-.57.17-.58-.43-.05-10.31-.27-20.53-.1-4.8 2.63-7.09c8.54-7.13 18.56-14.62 25.9-22.88z","M418.89 657.11q-1.12-1.67-.43-3.63 3.27-9.38 4.04-18.23 1.97-22.81 3.58-45.65c.16-2.32.72-6.41 2.84-7.71q14.97-9.23 27.16-21.93.41-.42.71.08 1.29 2.15 1.53 4.2 3.23 27.74 3.13 56.8a1.3 1.28-24.5 01-.33.86q-12.74 13.93-25.55 27.75c-4.8 5.17-9.09 7.87-15.73 7.96q-.61.01-.95-.5z"]},"quadriceps":{"left":["M292.42 935.6q-.95-.52-1.57-1.4-4.1-5.79-7-13.53-7.8-20.79-13.3-42.33c-9.06-35.53-19.33-71.36-25.03-107.59-5.33-33.86 4-74.19 20.7-103.37q.35-.62.53.07c14.44 55.57 39.03 107.94 41.45 165.34 1.11 26.34.66 52.96-3.6 79.03-.63 3.83-4.73 27.81-12.18 23.78z","M275.11 942.93q-2.42-2.18-3.57-5.24c-3.98-10.61-7.68-21.02-12.81-31.32-7.85-15.76-10.77-34.56-13.2-51.46-2.11-14.63-2.31-31.47-3.93-47.18-.22-2.16-1.04-12.78.46-13.79q1.36-.92 2.08.55c1.5 3.08 3.12 6.12 3.66 9.58q8.21 52.38 26.36 102.15c2.87 7.87 9.98 30.5 1.85 36.74a.71.7-42.5 01-.9-.03z","M322.69 945.72c-3.73 6.14-10.77-2.43-12.6-5.6-3.16-5.47-2.62-14.93-1.78-20.81 4.03-28.09 5.6-52.81 3.48-80.78q-.06-.79.28-.08 15.77 32.83 14.26 68.9c-.4 9.54-2.94 22.48-2.91 34.13q.01 3.02-.73 4.24z"],"right":["M437.82 933.52c-8.9 14.18-15.15-26.74-15.46-29.25q-5.26-43.04-1.19-86.08c4.9-51.8 26.91-99.32 40.38-150.92q.18-.66.5-.06c17.25 31.67 25.39 68.28 20.54 104.36q-2.29 17.02-8.71 42.76-7.56 30.25-15.2 60.47-6.13 24.25-15.06 47.61-1.83 4.79-5.8 11.11z","M451.79 942.6c-9.95-10.01 4.97-42.91 8.94-55.41q12.55-39.53 19.27-80.47c.49-2.97 2.64-12.34 5.41-13.28a.83.83 0 011.09.64q.74 4 .45 7.92c-1.99 26.52-3.37 58.99-11.01 87.73q-2.53 9.5-7.46 18.8c-4.38 8.24-6.97 16.72-10.08 25.27q-1.66 4.54-4.55 8.63a1.35 1.35 0 01-2.06.17z","M406.69 946.81c-3.24-2.77-1.48-10.64-2.01-14.71q-2.23-17.18-2.57-22.16c-1.75-25.07 3.61-49.11 13.98-71.92q.23-.51.2.05c-1.2 19.15-1.28 38.18.83 57.38q1.68 15.4 3.39 30.8c.43 3.92-.31 9.71-2.09 13.33-1.62 3.28-7.58 10.77-11.73 7.23z"]},"calves":{"left":["M252.09 1032.57c.24-3.71 2.14-22.17 4.63-24.18a1.03 1.02-17.9 011.67.85c-.45 7.89-1.27 16-1.49 23.45q-.57 18.93-.66 37.88-.02 3.63.34 6.85c2.08 18.76 5.56 37.32 9.3 55.8 3.82 18.84 9.13 37.64 13.11 56.63q2.44 11.68 2.08 17.95c-.32 5.7-3.08 20.49-8.51 23.92a.62.62 0 01-.84-.16q-1.2-1.65-.95-3.55c.92-7.26 1.45-14.15-.3-21.52q-8.25-34.74-13.62-59.06c-1.86-8.44-3.17-17.18-3.93-26.3q-3.69-44.24-.83-88.56z","M315.01 1025.17a.16.16 0 01.32.02c4.06 25.75 8.98 52.72 8.71 77.81q-.13 12.06-5.74 26.31c-7.2 18.3-8.93 38.57-15.95 56.93q-.18.48-.21-.03c-1.87-34.47-5.67-65.91-8.56-103.28q-.97-12.49 4.44-23.14 7.47-14.69 15.14-29.29c.81-1.55 1.35-3.62 1.85-5.33z"],"right":["M455.5 1231.67c-7.13-5.81-9.23-24.34-8.2-31.86 1.41-10.32 4.63-23.14 7.98-36.33q9.54-37.46 15.15-75.74c2.86-19.5 1.53-40.15.75-59.8-.22-5.67-.98-12.51-1.23-18.75a.97.97 0 011.87-.4c.35.86.92 1.76 1.12 2.68q2.96 14.31 3.31 20.53 2.37 43.28-.49 84.75-1.21 17.42-5.43 35.77-6.33 27.51-12.84 54.98-2.01 8.49-.11 18.36c.36 1.9.11 3.95-.68 5.55a.79.79 0 01-1.2.26z","M412.77 1025.44a.14.14 0 01.27-.04c4.88 11.62 10.93 22.01 17.28 34.78 4.07 8.19 4.71 14.41 4.1 24.25-2.13 34.3-6.27 68.85-8.45 101.59q-.05.69-.31.05-1.48-3.67-2.28-6.75c-4.34-16.75-8.78-38.38-16.39-57.57q-1.4-3.55-2.2-10.11c-1.78-14.73-.2-31.24 2.04-45.88q3.06-20.02 5.94-40.32z"]},"adductors":{"left":["M280.26 647.4c11.65 10.74 22.18 21.04 31.02 34.3 15.82 23.72 27.55 49.72 34.01 77.58 1.34 5.79-6.14 20.34-12.62 20.22q-.52-.01-.72-.49-.67-1.59-1.21-3.13c-14.68-41.71-27.96-79.71-46.87-117.01-1.9-3.74-3.05-7.33-4.06-11.2a.27.27 0 01.45-.27z","M331.64 898.32q-.17.57-.23-.02c-2.23-25.01-8.47-50.09-14.25-74.53q-19.4-82.1-42.46-163.69-.58-2.08.33-.13c19.88 42.53 38.94 86.51 51.64 132.07 9.49 34.06 15.59 71.67 4.97 106.3z","M334.46 789.17c1.56-2.63 14.39-20.38 16.2-20.37a1.71 1.7-89.2 011.7 1.76q-1.12 34.88-7.4 68.95c-.38 2.06-1.41 4.27-2.16 6.23q-.24.62-.34-.04-3.68-25.45-8.44-50.7c-.34-1.79-.63-4 .44-5.83z"],"right":["M395.47 779.4c-5.7 1.33-11.34-11.87-12.46-15.86q-.61-2.18-.02-4.65 10.17-42.64 35.06-78.81c9.47-13.77 18.83-22.36 29.85-32.56q.55-.5.4.22-1.12 5.7-3.73 10.83c-19.44 38.38-33.3 79.2-47.77 119.65a1.84 1.83-86.4 01-1.33 1.18z","M453.65 658.99q.67-1.43.23.09-26.73 93.75-48.63 189.74c-1.98 8.7-3.66 17.9-5.44 26.84q-2.19 11.05-2.78 22.43a.15.15 0 01-.3.04c-8.18-24.48-6.74-51.98-1.87-76.86 11.07-56.49 34.44-110.42 58.79-162.28z","M377.91 768.67c1.49.84 1.76 1.49 2.66 2.66q6.16 8.04 12.23 16.13c1.88 2.52 1.97 4.18 1.38 7.45q-4.57 25.23-8.43 50.57-.11.71-.4.05-1.89-4.29-2.54-8.09-5.57-32.28-6.98-65.01-.09-2 .81-3.44a.95.94 30.8 011.27-.32z"]},"trapezius":{"left":["M285.01 307.01a.89.89 0 01-.11-1.64q19.44-9.61 35.65-24.8 1.68-1.57 3.31-.31.4.32.45.82 1.25 12.61-1.57 25.41c-.74 3.32-2.55 4.23-5.9 4.48q-16.02 1.24-31.83-3.96z"],"right":["M414 311.19c-5.24-.12-7.81-.64-8.9-6.27q-2.33-12.09-1.17-23.94.06-.61.61-.89 1.66-.85 3.65.99 16.12 14.87 33.97 23.63 3.65 1.79-.27 2.89-13.88 3.91-27.89 3.59z"]},"neck":{"left":["M354.01 315.07q-3.49-3.65-5.9-8.23c-6.46-12.3-11.03-25.42-16.12-38.77-2.92-7.66-1.98-19.44-1.61-27.6q.03-.58.47-.21c9.06 7.39 11.33 17.46 15.67 27.62 5.4 12.61 15.4 33.31 9.11 46.92a1 .99 35.5 01-1.62.27z","M345.77 316c-4.12-1.96-12.78-6.76-15.07-11.38-4.29-8.65-2.69-16.02-2.28-25.25a1 1 0 011.95-.28c4.29 12.42 10.5 24.4 15.71 36.61q.23.55-.31.3z"],"right":["M372.75 314.71c-5.78-9.67 1.71-31.17 6.17-40.68 5.95-12.68 8.21-24.68 18.35-33.9a.49.49 0 01.82.35c.28 8.68.84 19.39-1.97 27.72-5.26 15.58-11.39 33.46-21.42 46.62a1.18 1.18 0 01-1.95-.11z","M398.01 278.49a.5.49 35.5 01.87-.14c2.01 2.7 1.62 11.6 1.61 15.13-.04 12.42-8.2 17.45-17.9 22.58a.35.35 0 01-.48-.46c5.51-12.02 11.85-24.46 15.9-37.11z"]},"forearm":{"left":["M127.23 683.05c-4.07-2.12 1.27-27.07 2.25-31.57 4.98-23.03 9.17-46.17 13.91-69.25q1.53-7.47 2.13-15.13c.93-12.09.81-22.15 6.23-31.59 7.1-12.33 13.54-29.16 26.1-36.73a1.98 1.97 62.7 012.84.91c1.92 4.48 1.93 8.28 2.06 14.15.44 19.77-1.3 41.04-8.72 59.67-11 27.62-22.22 55.21-32.62 82.91-4.04 10.76-7.56 20.66-12.82 26.39q-.59.65-1.36.24z","M201.5 527.4a.84.84 0 01.67.65c3.98 17.15-2.93 39.36-10.95 54.41-4.6 8.63-13.06 20.43-18.21 31.33q-13.21 27.92-24.58 56.64-2.51 6.35-6.61 11.02a1.43 1.43 0 01-2.5-.81q-.36-3.78.84-7.17 10.31-29.18 21.57-57.99c6.32-16.18 14.55-31.65 20.66-47.87 3.69-9.82 5.36-22.36 7.32-30.62 1.49-6.27 4.19-11.06 11.79-9.59z","M207.33 540.4a.6.59-63.1 011.03-.34l5.38 6.02q.4.45.33 1.06-.52 4.1-1.29 5.84-6.91 15.65-13.69 31.35c-5.41 12.53-16.33 28.4-23.51 44.89-8.3 19.08-16.03 39.32-26.75 57.16a.36.36 0 01-.62 0l-.19-.32q-.17-.28-.06-.59 10.08-29.91 23.05-58.65 2.9-6.42 5.47-11.21c4.62-8.59 10.86-16.17 14.62-23.02q13.23-24.13 16.23-52.19z"],"right":["M600.08 683.04c-5-4.14-8.97-15.46-11.29-21.56-5.82-15.25-11.38-30.55-17.58-45.7q-9.15-22.39-18.02-44.89c-5.58-14.19-7.32-31.42-7.99-46.57-.29-6.44-.68-19.43 2.67-25.02a1.71 1.71 0 012.25-.63c6.72 3.52 11.29 9.96 14.87 16.5q6.25 11.38 12.68 22.66c1.97 3.45 2.93 7.66 3.41 12.06 1.16 10.6 1.55 21.29 3.66 31.65 3.93 19.29 7.38 38.63 11.47 57.92 1.5 7.07 9.3 39.08 5.12 43.5a.91.91 0 01-1.25.08z","M586.58 681.46q-4.35-4.47-6.75-10.61-11.35-28.91-24.59-57.01c-5.72-12.13-14.32-22.86-19.97-35.1-7.1-15.36-12.9-33.32-9.27-50.31a1.44 1.43-87.1 011.23-1.12c7.47-.88 9.29 2.88 11.02 9.2 3.39 12.42 4.76 25.91 9.75 36.7 15.55 33.65 27.61 64.94 39.31 98.42 1.13 3.24 2.05 5.47 1.62 9.04a1.38 1.37 26.3 01-2.35.79z","M579.58 686.43q-3.92-5.77-6.87-12.13-8.05-17.34-19.75-44.5-2.68-6.24-6.46-13.62c-5.14-10.05-13.15-22.36-17.34-31.85q-9.55-21.68-13.66-31.36-1.09-2.58-1.33-5.87-.04-.61.37-1.07l5.24-5.85a.69.69 0 011.2.4q2.74 27.05 15.49 50.75 1.7 3.17 8.26 12.86 7.02 10.39 12.18 21.88 8.71 19.41 20.19 50.1 2.22 5.92 3.13 9.98a.36.36 0 01-.65.28z"]},"hands":{"left":["M100.98 745.85c-9.03-6.62-15.78-13.18-13.3-24.59 2.67-12.29 15.01-20.6 25.37-26.21 7.76-4.21 18.22-1.68 26.15.97 7.14 2.39 11.11 6.16 11.1 13.86q-.04 18.51-4.75 36.37c-5.47 20.76-34.48 6.99-44.57-.4z","M53.81 746.32a.91.91 0 01-.74-.95c.14-2.49-.23-6.34 2.25-7.8 4.66-2.71 11.37-5.53 14.15-10.3q6.32-10.86 16.56-20.3 1.27-1.17.64.44c-1.45 3.73-2.86 7.21-3.87 11.59-2.76 11.9-14.62 30-28.99 27.32z","M87.21 745.05c1.44.46 8.14 2.66 8.61 4.55 1.26 5.12-4.42 8.54-7 12.25-7.73 11.1-15.12 23.38-24.25 33.28a1.22 1.22 0 01-2.11-.86c.11-3.93.38-7.1 2.43-10.65q10.27-17.71 19.31-36.11.32-.65 2.13-2.27.38-.35.88-.19z","M108.11 758.12a2.16 2.16 0 011.07 2.87q-10.49 22.55-19.92 45.81c-1.45 3.56-4.37 5.15-7.82 6.04a1.35 1.34-8.1 01-1.69-1.26c-.11-3.05.37-5.87 1.58-8.9q8.1-20.28 15.15-40.96c.41-1.2.62-3.33 1.69-4.85a1.21 1.21 0 01.91-.49q4.72-.21 9.03 1.74z","M134.09 799.9q-1.16-1.7-1.41-3.73-2.1-17.07-1.18-34.29.03-.6.61-.75l6.93-1.85q.68-.19.65.52-.51 10.9-.85 21.71c-.28 8.58.1 12.65-4.17 18.4a.36.36 0 01-.58-.01z","M108.13 814.65a1.48 1.48 0 01-1.62-1.47c-.02-2.83-.14-5.66.32-8.53q2.9-17.79 5.4-35.65.53-3.84 1.58-7.56a.66.66 0 01.76-.48l7.26 1.24a.97.97 0 01.78 1.14q-4.76 23.96-9.1 46.26-.9 4.64-5.38 5.05z"],"right":["M591.31 755.99c-8.06-2.93-8.66-9.76-10.28-17.06q-3.22-14.42-3.1-29.3.04-4.06 1.46-6.55c4.34-7.57 18.16-9.91 25.63-10.35 8.75-.51 18.37 6.96 24.99 12.27q8.92 7.17 10.74 17.52c2.45 13.89-12.11 23.41-22.7 29.04-6.95 3.69-18.63 7.39-26.74 4.43z","M641.97 706.78q10.85 9.65 17.61 21.91c1.63 2.97 9.74 6.76 12.87 8.59 2.9 1.7 3.03 4.81 2.55 8.5q-.06.42-.48.49c-8.16 1.32-11.99-1.93-17.72-7.23-10.35-9.58-10.5-20.33-15.33-31.9q-.54-1.29.5-.36z","M638 760.07c-2.54-3.42-7.52-6.03-5.44-11.11q.18-.44.61-.63l7.41-3.3q1.29-.58 2.05.62 3.33 5.23 5.69 10.04 6.84 13.94 14.71 27.33c1.35 2.29 4.28 10.16 2.25 12.11a1.22 1.22 0 01-1.77-.08c-9.43-10.98-16.85-23.36-25.51-34.98z","M647.83 812.68c-4 .24-7.71-2.87-9.11-6.38q-9.28-23.27-19.74-45.33a2.05 2.05 0 01.92-2.71q4.5-2.28 9.62-1.7a1.09 1.07 83.8 01.89.73q7.5 23.06 16.57 45.5 1.8 4.46 1.5 9.24a.7.7 0 01-.65.65z","M596.17 761.18a.84.84 0 01.62.81c-.01 4.86.95 35.3-2.71 37.67q-.49.32-.82-.17-3.41-5.21-3.51-8.49-.45-15.62-1.16-31.23-.03-.72.66-.52l6.92 1.93z","M621.09 814.28c-4.35 1.91-5.92-3.77-6.5-6.56q-4.52-21.91-8.88-43.95a1.41 1.41 0 011.14-1.66l6.8-1.18a.92.92 0 011.06.76q2.79 16.32 5.09 32.91c.85 6.17 2.2 12.25 1.8 18.95q-.03.52-.51.73z"]},"knees":{"left":["M297.69 1008.37c-7.27 7.29-16.34 3.42-19.64-5.18q-6.18-16.11-9.57-30.68c-1.99-8.6-2.24-19.68 9.72-19.91q13.12-.24 26.05 2.15 1.71.32 3.29 1.02a1.17 1.15 4.2 01.63.72c3.17 10.27 2.5 23.36.05 33.69q-2.37 10.01-10.53 18.19z","M288.03 1059.54c-6.99-5.81 13.75-46.43 17.3-53.91q7.3-15.38 10.9-32.01c.74-3.42 2-6.31 4.18-8.64a1.36 1.35 54.7 012.23.39c3.97 9.09 1.66 13.86-1.67 24.65q-10.23 33.19-27.2 63.57-1.8 3.23-4.2 5.84a1.13 1.12-49 01-1.54.11z"],"right":["M430.44 1008.31c-12.92-12.62-14.34-33.49-10.92-50.31.31-1.53 1.09-2.53 2.73-2.86q11.44-2.25 23.08-2.59c14.13-.42 17.31 5.67 14.54 18.63q-3.13 14.69-9.12 30.37c-3.45 9.03-11.63 15.25-20.31 6.76z","M438.96 1059.52q-2.25-1.89-3.8-4.64-20.15-35.92-31.06-75.66-2.11-7.68 1.95-14.16a1.16 1.16 0 011.91-.08c2.26 3.06 3.4 5.4 4.26 9.37 3.98 18.54 10.94 32.53 20.07 51.09 3.51 7.14 11.38 26.16 8.5 33.61a1.16 1.16 0 01-1.83.47z"]},"tibialis":{"left":["M263.52 973.59a.6.6 0 011.09-.14q1.38 2.22 1.83 5.06c7.87 49.97 18.01 99.59 25 149.68q4.63 33.19 4.31 67.55-.04 3.45-2.15 5.76-.4.44-.75-.03-1.89-2.58-3.08-5.51c-11.63-28.6-20.46-58.12-24.26-88.68q-4.96-39.97-5.72-69.53c-.13-5.27-.17-12.59.35-18.98q1.7-20.77 2.52-41.6c.04-1.16.52-2.43.86-3.58z"],"right":["M463.39 973.68a.7.7 0 011.25-.1c.27.46.64 1.34.68 1.93q1.26 20.88 2.53 41.76.66 10.82.39 19.98-1.23 40.77-7.51 82.25c-3.91 25.87-12.19 51.55-21.96 75.76q-1.13 2.79-3.27 6.13-.29.44-.71.12c-2.68-2.06-2.32-6.7-2.29-10.32.26-31.03 2.71-55.52 8.76-91.4q9.27-55.06 18.94-110.05c.8-4.5.99-10.52 3.19-16.06z"]},"ankles":{"left":["M291.88 1208.11c5.48-1.03 11.85 5.55 13.38 10.37q2.45 7.74 1.47 16.83-.09.83-.45.08c-4.31-9.05-8-16.99-15.39-23.88a1.98 1.98 0 01.99-3.4z","M275.88 1270.94c-4.41-3.87-7.4-7.17-4.91-13.37q4.78-11.92 5.49-21.32.62-8.27 6.22-12.84c9-7.33 20.8 15 23.1 22.1 2.55 7.91 4.83 16.36 4.49 24.5-.31 7.14-2.02 17.4-6.49 23.1q-.3.38-.53-.05c-5.67-10.74-18.6-14.41-27.37-22.12z"],"right":["M430.92 1209.12c2.24-1.35 10.54-2.02 6.02 2.65q-9.99 10.32-14.82 23.8a.28.28 0 01-.55-.08c-.52-10.27-.48-20.45 9.35-26.37z","M445.01 1223.26c8.45 6.56 6.46 16.66 9.35 25.59q1.76 5.43 3.47 10.88c3.84 12.26-27.75 21.49-32.21 32.42q-1.02 2.51-2.17.05c-6.91-14.82-6.79-29.36-1.78-44.58q2.82-8.57 8.02-16.04c3.02-4.35 9.61-12.76 15.32-8.32z"]},"feet":{"left":["M264.5 1334.5c-3.98-.34-18.59-4.25-19.04-9.44a1.4 1.4 0 01.27-.94c9.66-13.03 20.9-25.49 28.65-39.78q.25-.47.78-.37 9.76 1.78 17.73 7.65a1.19 1.18 43 01.07 1.86c-1.32 1.11-1.65 2.62-1.06 4.35 2.96 8.57-.92 16.55-4.81 25.34-1.79 4.06-1.76 8.99-2.81 13.62a1.56 1.56 0 01-1.99 1.14q-8.36-2.64-17.79-3.43z","M291.87 1340.12c-2.25-2.64-2.07-5.93-.78-9.35q3.34-8.88 4.02-18.35.43-6.02 1.25-8.74 1.32-4.37 3.45-8.22a.66.65 53.7 011.21.19q1.97 9.26 6.28 17.3c2.59 4.85-.82 11.49-2.92 16.14a1.81 1.78-35.8 00-.16.94q.42 4.3-1.9 7.94-.22.33-.61.43l-8.79 2.06a1.06 1.06 0 01-1.05-.34z"],"right":["M444.66 1337.65q-1.08-1.3-1.28-3.09c-.52-4.48-.73-8.39-2.77-12.64-3.51-7.31-7.06-16.37-4.43-23.19.77-1.99.92-3.79-.76-5.13a1.29 1.28 46.4 01.04-2.04q7.96-5.76 17.59-7.64.46-.1.69.32c7.25 13.1 17.21 24.83 26.45 36.56q1.11 1.41 2.51 3.8a1.17 1.14-51 01.09.95c-1.75 5.01-12.93 7.89-17.77 8.55q-9.87 1.36-19.54 3.82a.82.8-26.2 01-.82-.27z","M426.94 1338.55c-2.01-.34-2.96-5.48-3-7.12-.15-6.02-6.29-11.65-3.12-17.89q4.35-8.53 6.34-17.75a.78.78 0 011.47-.17c2.12 4.52 4.18 9.08 4.35 14.33q.35 10.43 3.97 20.24c1.19 3.22 1.52 5.83.39 8.78a2.32 2.31 19.3 01-2.87 1.38q-3.44-1.09-7.53-1.8z"]},"serratus":{"left":["M268.5 460.2c-2.1-1.3-3.8-3.9-3.2-6.4q1.2-5.1 4.8-8.3c2.4-2.1 5.6-1.8 8.2-.4q4.3 2.3 6.1 7.1c1.2 3.2-.3 6.8-3.1 8.4q-5.8 3.3-12.8-.4z","M262.3 478.5c-1.8-1.6-2.9-4.2-2.1-6.7q1.5-4.8 5.4-7.2c2.6-1.6 5.8-.8 8.1 1.1q3.7 3.1 4.2 8.2c.3 3.4-1.5 6.3-4.6 7.4q-5.6 2-11-.8z","M258.1 498.3c-1.5-1.9-2.2-4.6-1.1-7q2-4.3 6.1-6.1c2.8-1.2 5.9.1 7.8 2.4q3.1 3.7 2.8 8.9c-.2 3.5-2.3 5.9-5.6 6.5q-5.3 1.2-10-4.7z"],"right":["M456.8 460.2c2.1-1.3 3.8-3.9 3.2-6.4q-1.2-5.1-4.8-8.3c-2.4-2.1-5.6-1.8-8.2-.4q-4.3 2.3-6.1 7.1c-1.2 3.2.3 6.8 3.1 8.4q5.8 3.3 12.8-.4z","M462.9 478.5c1.8-1.6 2.9-4.2 2.1-6.7q-1.5-4.8-5.4-7.2c-2.6-1.6-5.8-.8-8.1 1.1q-3.7 3.1-4.2 8.2c-.3 3.4 1.5 6.3 4.6 7.4q5.6 2 11-.8z","M467.1 498.3c1.5-1.9 2.2-4.6 1.1-7q-2-4.3-6.1-6.1c-2.8-1.2-5.9.1-7.8 2.4q-3.1 3.7-2.8 8.9c.2 3.5 2.3 5.9 5.6 6.5q5.3 1.2 10-4.7z"]},"hipFlexors":{"left":["M305.2 700.5c-3.8-2.1-7.2-6.3-8.1-10.7q-1.4-6.8 1.2-13.1c1.8-4.3 5.9-7.1 10.3-7.8q6.2-.9 11.4 2.8c3.5 2.5 5.2 6.9 4.8 11.2q-.6 6.7-5.3 11.9c-3.1 3.4-8.1 8.8-14.3 5.7z"],"right":["M421.5 700.5c3.8-2.1 7.2-6.3 8.1-10.7q1.4-6.8-1.2-13.1c-1.8-4.3-5.9-7.1-10.3-7.8q-6.2-.9-11.4 2.8c-3.5 2.5-5.2 6.9-4.8 11.2q.6 6.7 5.3 11.9c3.1 3.4 8.1 8.8 14.3 5.7z"]},"upperChest":{"left":["M275.8 345.1c5.2-4.8 14.3-8.2 21.6-9.4q12.8-1.8 24.5.5c6.4 1.2 11.2 6.3 13.4 12.5q3.2 9.1 1.8 18.8c-.9 6.2-5.1 10.4-10.8 12.9q-8.5 3.7-18.6 2.4c-6.8-.9-13.2-3.1-18.4-7.8q-7.7-6.9-13.5-29.9z"],"right":["M449.5 345.1c-5.2-4.8-14.3-8.2-21.6-9.4q-12.8-1.8-24.5.5c-6.4 1.2-11.2 6.3-13.4 12.5q-3.2 9.1-1.8 18.8c.9 6.2 5.1 10.4 10.8 12.9q8.5 3.7 18.6 2.4c6.8-.9 13.2-3.1 18.4-7.8q7.7-6.9 13.5-29.9z"]},"lowerChest":{"left":["M279.3 394.2c3.1-2.8 8.9-5.1 13.2-5.9q9.8-1.8 19.8.3c5.6 1.2 10.1 4.8 12.8 10.1q3.8 7.5 2.4 15.9c-1.1 6.1-5.8 9.8-11.2 11.8q-7.6 2.8-16.1 1.1c-5.7-1.1-10.8-3.8-14.5-8.5q-5.5-7-6.4-24.8z"],"right":["M446.2 394.2c-3.1-2.8-8.9-5.1-13.2-5.9q-9.8-1.8-19.8.3c-5.6 1.2-10.1 4.8-12.8 10.1q-3.8 7.5-2.4 15.9c1.1 6.1 5.8 9.8 11.2 11.8q7.6 2.8 16.1 1.1c5.7-1.1 10.8-3.8 14.5-8.5q5.5-7 6.4-24.8z"]},"innerQuad":{"left":["M316.5 780.2c-2.8-1.4-4.1-4.8-4.2-8.1q-.3-12.8 2.1-25.3c1.6-8.2 4.8-15.8 9.2-22.6q5.7-8.8 13.1-15.9c2.4-2.3 5.8-1.2 7.1 1.8q3.2 7.4 3.8 15.6c.8 11.2-.4 22.5-3.2 33.4q-2.8 10.8-8.1 18.4c-3.4 4.8-12.8 6.2-17.8 2.7z"],"right":["M410.2 780.2c2.8-1.4 4.1-4.8 4.2-8.1q.3-12.8-2.1-25.3c-1.6-8.2-4.8-15.8-9.2-22.6q-5.7-8.8-13.1-15.9c-2.4-2.3-5.8-1.2-7.1 1.8q-3.2 7.4-3.8 15.6c-.8 11.2.4 22.5 3.2 33.4q2.8 10.8 8.1 18.4c3.4 4.8 12.8 6.2 17.8 2.7z"]},"outerQuad":{"left":["M258.4 810.5c-3.2-5.8-4.8-13.2-4.1-19.8q1.2-11.8 5.8-22.8c3.1-7.4 7.8-14.1 13.5-19.8q7.2-7.2 16.1-12.4c2.8-1.6 6.1.2 6.8 3.5q1.8 8.4 1.2 17.2c-.8 12.1-3.8 24.1-8.4 35.2q-4.6 11.1-11.8 19.8c-4.6 5.5-14.5 7.1-19.1-0.9z"],"right":["M468.3 810.5c3.2-5.8 4.8-13.2 4.1-19.8q-1.2-11.8-5.8-22.8c-3.1-7.4-7.8-14.1-13.5-19.8q-7.2-7.2-16.1-12.4c-2.8-1.6-6.1.2-6.8 3.5q-1.8 8.4-1.2 17.2c.8 12.1 3.8 24.1 8.4 35.2q4.6 11.1 11.8 19.8c4.6 5.5 14.5 7.1 19.1-0.9z"]},"upperAbs":{"left":["M315.8 448.2c-2.1-1.8-3.2-5.1-2.8-8.1q.8-6.2 4.1-11.4c2.2-3.4 5.8-5.1 9.6-5.3q6.2-.3 11.8 2.1c3.8 1.6 5.8 5.4 5.4 9.6q-.6 6.8-4.8 12.1c-2.8 3.5-6.8 4.8-11.1 4.2q-7.1-1-12.2-3.2z"],"right":["M411.5 448.2c2.1-1.8 3.2-5.1 2.8-8.1q-.8-6.2-4.1-11.4c-2.2-3.4-5.8-5.1-9.6-5.3q-6.2-.3-11.8 2.1c-3.8 1.6-5.8 5.4-5.4 9.6q.6 6.8 4.8 12.1c2.8 3.5 6.8 4.8 11.1 4.2q7.1-1 12.2-3.2z"]},"lowerAbs":{"left":["M320.1 620.5c-3.1-2.4-4.8-6.8-4.2-10.8q1.1-7.2 5.1-13.2c2.6-3.8 6.4-5.8 10.8-5.4q6.8.6 12.1 4.8c3.4 2.7 4.8 7.1 3.8 11.4q-1.6 6.8-6.8 11.1c-3.4 2.8-7.8 3.8-12.1 3.1q-5.1-.8-8.7-1z"],"right":["M407.2 620.5c3.1-2.4 4.8-6.8 4.2-10.8q-1.1-7.2-5.1-13.2c-2.6-3.8-6.4-5.8-10.8-5.4q-6.8.6-12.1 4.8c-3.4 2.7-4.8 7.1-3.8 11.4q1.6 6.8 6.8 11.1c3.4 2.8 7.8 3.8 12.1 3.1q5.1-.8 8.7-1z"]},"frontDeltoid":{"left":["M261.2 338.5c-3.2-1.8-5.1-5.4-4.8-9.1q.5-6.2 4.2-11.1c2.5-3.3 6.2-4.8 10.1-4.1q5.8 1.1 9.8 5.4c2.7 2.9 3.4 7.1 2.1 10.8q-2.1 5.8-7.4 8.8c-3.5 2-9.2 2.1-14-.7z"],"right":["M464.1 338.5c3.2-1.8 5.1-5.4 4.8-9.1q-.5-6.2-4.2-11.1c-2.5-3.3-6.2-4.8-10.1-4.1q-5.8 1.1-9.8 5.4c-2.7 2.9-3.4 7.1-2.1 10.8q2.1 5.8 7.4 8.8c3.5 2 9.2 2.1 14-.7z"]},"head":{"left":[],"right":[]},"hair":{"left":[],"right":[]}},"MALE_BACK_PATHS":{"neck":{"left":["M1022.74 290.63a.62.61 25.9 01-.36-1.03q1.71-1.83 4.11-3.11c8.19-4.35 19.4-8.3 23.38-17.48q8.48-19.57 8.22-40.85-.05-4.38.57-5.76c1.98-4.38 9.65-3.66 13.85-2.91 4.3.76 4.71 3.25 4.68 7.3q-.2 24.11-.88 48.2c-.12 4.25 1.6 15.84-4.88 16.32-14.57 1.08-32.6 1.81-48.69-.68z"],"right":["M1095.75 291.46c-4.3-.25-4.9-3.99-4.95-7.71q-.46-29.47-1-58.94c-.13-7.39 11.74-6.23 15.99-4.85 4.2 1.36 3.01 6.89 2.88 10.79-.28 8.88 5.15 41.1 15.32 46.78q8.6 4.81 17.27 9.51 1.97 1.07 3.26 2.36a.8.79 63.6 01-.45 1.35c-16.12 2.17-33.78 1.56-48.32.71z"]},"trapezius":{"left":["M1071.06 308.94c5.6 4.92 6.96 17.83 7.43 24.88q1.5 22.3.93 44.68-1.2 46.76-5.66 94a.57.56 3.7 01-.59.51q-.68-.03-.94-1.01-4.29-15.9-9.79-25.19c-10.24-17.31-18.8-31.84-25.59-49.4-10.19-26.38-15.6-54.28-26.46-80.58q-3.07-7.43-7.61-14.07-.3-.43.2-.6 12.47-4.28 25.48-4.85c5.54-.25 12.15.86 18.32 1.41 9.7.87 16.77 3.6 24.28 10.22z"],"right":["M1163.98 302.12a.43.43 0 01.22.65q-7.08 10.77-11.41 23.37c-10.53 30.61-17.8 62.94-31.3 91.07-5.11 10.64-15.17 25.22-20.12 36.26q-4.08 9.08-6.59 18.83a.77.77 0 01-1.51-.12q-4.27-45.15-5.52-90.99c-.56-20.28-.74-39.92 2.75-60.43 1.04-6.13 2.77-9.98 7.85-13.85 9.8-7.48 18.02-7.73 30.1-9.11 12.02-1.39 23.92.4 35.53 4.32z"]},"deltoids":{"left":["M980.66 319.58c.19.14.55.19.65.32a.8.8 0 01-.16 1.15c-6.78 4.75-15.26 9.77-20.03 15.58-6.41 7.78-8.76 16.96-9.44 27.04-.39 5.92-1.68 9.5-5.59 13.43-10.02 10.08-19.04 16.47-31.14 20.41q-.75.25-.75-.55.19-18.4-.09-36.3-.14-9.4 1.07-14.22c4.04-16.07 22.8-33.85 39.68-35.64 9.99-1.06 17.34 2.46 25.8 8.78z"],"right":["M1227.3 316.44c14.62 9.44 25.48 21.03 25.46 39.51q-.02 20.56-.01 41.37a.37.37 0 01-.51.35c-5.08-2.06-10.41-3.98-14.9-6.97-7.84-5.24-21.14-14.95-21.77-24.95-.69-10.75-2.81-20.85-9.76-29.25-4.68-5.65-12.96-10.58-19.6-15.26q-1.23-.87.01-1.71c4.6-3.13 9.91-6.78 15.25-7.98q13.58-3.03 25.83 4.89z"]},"upperBack":{"left":["M987.06 381.44c-8.48-5.06-14.14-13.28-18.82-22.92q-5.3-10.92-6.46-14.04c-1.49-4.01 35.14-19.22 39.61-20.97q2.75-1.08 4.33-.72c4.33.96 6.61 9.96 7.46 13.7q5.43 23.89 14.65 55.74.78 2.7-.88 4.39c-5.37 5.5-34.69-12.08-39.89-15.18z","M1017.44 583.31q-9.11-9.57-16.97-22.03-2.28-3.62-2.91-7.25c-3.28-18.82-5.77-38.04-10.52-56.55-3.53-13.73-4.74-25.19-6.61-41.43-.85-7.35-5.67-13.34-8.22-18.75q-4.93-10.47-6.44-22.88-.33-2.72 1.89-1.11c7.25 5.27 16.36 6.16 26.91 7.56 8.86 1.19 23.41-3.18 28.94-10.76 3.34-4.58 4.7-6.5 8.86-8.77a.67.66-26.4 01.92.3q10.02 21.8 19.93 43.78c2.56 5.69 12.11 15.88 10.77 21.83-3.65 16.09-9.88 31.96-16.24 47.13-9.72 23.21-18.61 46.72-27.2 70.36q-.24.67-.88.35-1.03-.52-2.23-1.78z","M1017.71 404.73c-23.86 13.25-54.31 7.11-60.45-22.75-1.2-5.81-2.5-15.84.64-20.55 3.63-5.44 7.17 4.18 8.17 6.14 7.71 15.14 31.62 29.16 48.2 31.13q1.84.21 5.26 2.06.4.21.26.64-.86 2.65-2.08 3.33z"],"right":["M1141.45 397.63a2.17 2.14-3.6 01-1.88-1.64q-.71-2.97.18-5.95 8.74-29.19 11.75-43.29c1.73-8.11 3.07-16.77 6.94-22.08 1.92-2.62 4.28-2.27 7.19-1.15q20.52 7.9 39.09 18.77a1.37 1.36 25.9 01.58 1.67c-6.05 15.46-12.98 30.84-28.43 39.45-9.45 5.26-25.83 15.17-35.42 14.22z","M1149.69 404.8q-2.04-1.15-2.45-3.5-.09-.53.41-.75c4.64-2.04 9.78-2.51 14.63-3.87 11.01-3.1 22.03-10.83 30.34-18.57q6.33-5.89 7.58-8.93c1.02-2.49 3.79-9.5 7-9.46q.52.01.87.39 2.71 3.01 2.81 7.2c.33 13.77-2.24 26.93-13.26 35.95-13.88 11.36-33.12 9.94-47.93 1.54z","M1161.19 419.98c6.1 1.57 11.6.99 17.75.06 8.36-1.27 14.83-2.76 21.34-7.27a.54.53 74.1 01.84.47q-.64 11.88-5.76 22.85c-2.42 5.2-6.64 10.84-8.04 16.67q-1.02 4.24-1.43 8.92-1.64 18.72-6.34 37.47c-4.73 18.91-7.13 38.67-10.8 57.85q-.24 1.24-2.2 4.3c-4.57 7.14-12.22 19.43-19.34 23.88a.44.43-25.6 01-.64-.22c-8.26-22.57-16.6-45.11-25.91-67.23-6.67-15.85-13.27-32.14-17.27-48.42q-1.58-6.41 2.91-12.01 5.21-6.51 8.57-14.14 9.25-21 19.01-41.64a.47.47 0 01.65-.21q6.17 3.37 9.51 9.64c2.45 4.6 12.22 7.75 17.15 9.03z"]},"triceps":{"left":["M931.03 442.29c-2.01 2.57-6.52 9.71-10.12 9.17q-.52-.08-.8-.52-1.35-2.09-1.84-4.44c-2.25-10.87-3.28-22.88 1.35-33.38 5.45-12.33 18.27-23.68 29.61-31.2a.47.46 68.7 01.71.32l6.42 38.52q.09.54-.26.97c-.47.58-1.12 1.52-1.71 1.94q-9.11 6.58-18.08 13.36-2.9 2.2-5.28 5.26z","M958.15 427.11a.41.41 0 01.55.27q4.44 16.16-2.23 31.41-3.37 7.73-5.91 19.98c-1.51 7.28-8.93 12.21-11.81 18.82-2.42 5.56-2.41 12.5-3.51 16.66-2.14 8.06-8.51 14.15-13.91 20.13a.93.93 0 01-1.54-.25q-.57-1.3-.75-2.89c-1.93-16.91 2.52-33.52 5.71-49.99 2.16-11.21-1.54-24.15 9.68-34.59q9.54-8.86 19.55-17.23c1.3-1.08 2.7-1.72 4.17-2.32z","M903.57 519.67a1.84 1.82-5.4 01-1.12-.92q-3.54-6.97-3.68-15.19c-.37-21.2 3.8-42.53 9.5-63.44q.33-1.23.92-.1 4.64 8.78 8.6 18.67c2.88 7.21 4.19 12.98 1.88 20.57q-6.07 19.96-14.02 39.23-.65 1.58-2.08 1.18z"],"right":["M1213.94 424.56q-2.02-1.5-3.08-3.02-.31-.46-.22-1 3.32-19.22 6.42-38.46.09-.56.56-.25 14.9 9.82 24.8 22.71c9.8 12.75 9.72 30.37 5.41 45.13a2.62 2.62 0 01-3.76 1.57c-3.26-1.77-6.22-6.71-8.62-9.67-5.24-6.46-14.75-12-21.51-17.01z","M1246.2 534.5q-.95-.3-1.75-1.22c-4.65-5.4-9.13-9.88-11.46-15.51-2.96-7.13-1.37-15.5-5.64-22.09-4.06-6.26-8.72-9.91-10.89-17.58-1.62-5.68-2.81-11.46-4.97-17.02-4.56-11.69-6.45-20.86-3.33-33.56a.59.58-74 01.75-.42q1.69.56 3.22 1.79 11.23 9.08 21.54 19.18c5.39 5.28 6.92 10.13 7.24 18.16.9 22.52 10.62 44.97 6.59 67.49a1.01 1 13.9 01-1.3.78z","M1258.43 439.96q2.01 5.38 3.1 10.68c3.58 17.36 7.13 34.77 6.89 52.61q-.11 8.3-3.94 15.61a1.61 1.6 33.4 01-2.44.5c-1.45-1.19-1.9-3.58-2.43-4.94q-9.23-23.41-13.19-38.15c-2.63-9.81 6.82-27.63 11.53-36.35q.28-.5.48.04z"]},"lowerBack":{"left":["M986.76 627.1c-3.13-13.13-7.31-49.77 7.27-58.07 2.4-1.37 4.8-.82 6.7 1.29 6.15 6.8 16.22 18.56 18.77 28.15a1.35 1.3 52.6 01-.11.98c-2.51 4.53-9.96 8.09-15.83 11.36q-5.47 3.06-11.33 10.52c-1.23 1.56-2.6 4.3-4.5 6.06a.59.58-28.2 01-.97-.29z","M1023.15 607.96a2.06 2.04-74.3 01-.94-1.69c-.17-10.98 5.04-24.58 8.79-34.9q15.61-42.83 36-83.59a1.11 1.1-62.5 011.51-.48c1.25.66 3.21 12.98 3.46 15.08q6.94 59.25 2.82 116.88-.62 8.66-3.1 19.37-.13.53-.59.24l-47.95-30.91z"],"right":["M1090.76 581.75q.62-5.16 0-10.27.22-29.79 3.05-59.5 1.1-11.58 3.91-22.88.31-1.27.44-1.43 1.08-1.43 1.88.17 23.38 46.97 40.14 96.18c1.8 5.28 5.84 16.69 4.38 22.96a1.64 1.64 0 01-.71 1.01l-47.63 30.72q-1.12.72-1.34-.6-4.54-28-4.12-56.36z","M1151.19 603.31q-5.39-3.38-2.19-9.05 8.03-14.22 17.88-24.62c3.49-3.69 9.04.89 10.97 3.99q2.92 4.66 3.8 10.14 3.5 21.77-1.21 43.02a.96.96 0 01-1.77.28c-6.92-11.85-16.03-16.56-27.48-23.76z"]},"forearm":{"left":["M878.44 534.38a.15.15 0 01.18-.13c.47.12 6.68 15.77 7.07 17.22q6.66 24.73 5.52 50.29c-.4 8.9-3.45 17.35-6.64 25.55-7.94 20.38-17.41 41.88-29.59 60.09a1.04 1.02-54.2 01-1.49.25c-.34-.26.37-1.45.47-1.83q5.58-20.8 8.97-42.08 8.65-54.15 15.51-109.36z","M893 518.93a.39.38 24.6 01.69-.25q5.97 7.83 13.11 15.27c8.08 8.4 1.41 28.73-5.88 37.12a1.05 1.05 0 01-1.63-.05c-6.09-7.93-5.41-18.74-4.97-28.44.36-8.12-.76-15.7-1.32-23.65z","M869.06 547.19c2.16.36 1.67 6.21 1.57 7.8q-2.54 38.84-9.11 77.16c-3.04 17.71-8.47 41.3-22.09 54.09a.38.38 0 01-.62-.41c14.51-40.44 19-84.26 26.8-126.31q.9-4.88 1.48-10.82.18-1.81 1.97-1.51z","M864.24 682.58q15.09-28.18 25.12-58.55c8.14-24.63 13.67-42.4 20.79-60.35q3.31-8.37 12.08-9.63c1.35-.2 3.68-.75 4.86.21q1.13.93.61 2.3-5.8 15.45-12.04 29.88c-5.79 13.39-14.92 28.68-20.32 40.14-6.12 13-28.07 59.18-31.64 56.64a.21.21 0 01.03-.36q.15-.07.34-.13.12-.04.17-.15z"],"right":["M1272.99 519.43c.27-.33.33-.75.75-1.05a.32.32 0 01.5.29c-.7 7.22-1.77 14.33-1.66 21.54.13 8.94 2.13 24-5.35 31.17q-.37.35-.73 0c-7.63-7.55-14.2-28.29-6.52-36.92q6.6-7.41 13.01-15.03z","M1312.82 688.04c-4.78-6.01-7.2-10.8-11.76-19.56q-12.39-23.79-21.03-47.53c-4.86-13.36-5.22-26.17-3.83-40.19q1.13-11.5 2.69-19.53 2.72-13.98 9.59-26.79a.17.17 0 01.32.06q7.26 63.12 17.22 120.49 2.43 14.04 7.03 30.55c.22.79.74 1.33.36 2.4a.34.34 0 01-.59.1z","M1296.52 558.51c-.22-2.94-1.44-10.25 2-12.04a.62.61-18.4 01.89.44q6.25 35.69 12.21 71.07c3.88 23 8.77 46.2 16.73 68.19a.29.29 0 01-.47.31c-11.67-10.67-18.09-31.15-20.89-45.98q-7.27-38.55-10.47-81.99z","M1303.5 683.6c-2.89-.66-10.16-13.21-12.11-17.02-8.8-17.21-16.92-34.81-25.84-51.89-5.36-10.27-10.98-20.49-15.39-30.95q-5.86-13.86-11.07-27.8a1.63 1.62 79.5 011.5-2.2c13.02-.16 15.5 7.18 19.65 18.81q9.04 25.33 17.43 50.89 9.65 29.37 23.82 56.84.87 1.69 2.13 3.12.24.28-.12.2z"]},"gluteal":{"left":["M1045.06 626.19q1.42.61 4.11 4.4.27.39-.19.52c-14.47 4.12-26.13 7.4-38.13 15.77q-15.37 10.71-30.53 21.6a.55.54 74.9 01-.86-.5c1.19-13.13 10.35-35.23 20.46-45.06 9.14-8.88 34.99-1.11 45.14 3.27z","M1007.94 762.81c-16.94-16.64-29.37-37.66-31.47-61-2.06-22.84 15.63-34.95 32.18-45.71 8.2-5.33 46.51-27.32 54.37-17.65 5.92 7.29 13.38 15.84 15.44 25.21q3.01 13.63 2.44 27.6-.94 22.59-6.27 44.49c-2.43 9.96-2.9 17.16-2.59 26.75.47 14.83-18.52 17.18-29.12 14.07-6.38-1.87-13.79-4.83-21.35-6.25q-7.39-1.38-13.63-7.51z"],"right":["M1117.94 631.04q-.13-.03-.27-.06-.12-.02-.06-.13 2.58-4.2 7.05-5.92 12.71-4.87 26.13-5.81c12.93-.91 17.1 3.08 23.28 13.06 5.71 9.22 13.32 24.7 13.44 36.06q.01.76-.61.32-16.65-11.74-33.2-23.51c-10.03-7.14-23.72-10.58-35.76-14.01z","M1124.12 776.61c-9.28 2.74-26.75 1.29-28.86-10.88-1.05-6.03.27-14.88-1.3-23.27q-.54-2.94-2.15-9.35c-3.2-12.81-4.02-23.33-5.08-35.27-1.07-12.03-.57-22 1.64-33.17q1.1-5.6 4.19-10.41 8.74-13.58 11.87-16.59c4.96-4.77 15.84.18 21.19 2.11q19.7 7.12 40.17 21.43c9.59 6.7 19.29 14.31 22.93 25.17 4.81 14.37-.65 33.88-7.42 46.87q-7.79 14.97-21.39 28.9-6.74 6.9-15.26 8.36c-7.07 1.21-13.68 4.08-20.53 6.1z"]},"adductors":{"left":["M1070.06 785.19c2.95 1.36 1.8 10.43 1.49 13.04q-3.98 33.27-14.66 64.61a.39.39 0 01-.76-.17c.9-7.05 2.31-14.29 2.16-20.92q-.68-30.14-18.71-54.52-.29-.39.18-.49c7.42-1.52 23.53-4.69 30.3-1.55z"],"right":["M1127.24 787.66c-15.99 21.49-22.3 48.51-16.08 74.83a.47.46-63.2 01-.88.29q-1.99-4.69-3.65-10.24-8.29-27.75-11.6-56.54c-.65-5.71-1.1-11.77 6.87-11.9q13-.19 25.68 2.83a.31.24 41.2 01.1.53q-.12.01-.27.07-.1.04-.17.13z"]},"hamstring":{"left":["M963.27 741.53a.71.7 31.7 011.19-.28q1.51 1.62 2.47 3.99c4.6 11.41 8.93 22.66 11.07 34.72 3.38 19.14 4.84 38.23 3.12 57.74q-1.68 19.06-2.99 38.15c-.51 7.55-.88 15.71.07 23.18q1.08 8.54 1.39 17.57a.52.52 0 01-.98.25q-1.03-2.07-1.8-4.62-5.13-16.92-7.25-34.49-5.01-41.45-6.86-83.17-1.09-24.75-.07-49.51.06-1.59.64-3.53z","M1030.2 791.53q.17-.36.38-.03c5.26 8.11 9.94 16.15 12.47 25.64 3.12 11.72 5.87 24.36 4.31 36.24q-.5 3.8-3.57 14.02c-10.75 35.81-12.83 74.2-18.5 111.1q-.82 5.4-2.55 10.55-.23.68-.59.07c-4.72-8.07-5.18-25.09-5.34-34.81-.7-43.69 1.92-87.82 6.38-131.28 1.41-13.74 1.99-21.15 7.01-31.5z","M998.81 761.94q14.07 14.17 20.1 33.62c.98 3.15-.78 9.61-.93 12.91q-1.3 27.63-2.3 55.27c-.55 15.31-1.54 30.27-5.12 45.26q-8.62 36.18-22.76 68.73-3.65 8.41-10.15 17.19-.45.61-.41-.14c.11-1.93.82-4.15.99-5.71q2.45-22.72 6.08-45.26c2.83-17.66 4.18-35.95 4.33-52.37.33-36.43-.75-73.34 1.47-109.68.33-5.32 1.07-16.16 4.7-20.25q.33-.36.81-.45 1.95-.37 3.19.88z","M1052.52 855.62a.04.04 0 01.08.01q1.07 9.9 2.17 19.87.33 3.04-2.37 14.18c-3.83 15.8-8.15 31.11-8.9 47.47-.99 21.61-3.11 45.66-9.92 66.3q-1.49 4.52-.87-.2 3.38-25.36 3.7-51.99c.05-3.74-.4-10.32.2-15.58 2.19-19.2 7.39-38.25 11.75-57.05 1.78-7.64 2.93-15.21 4.16-23.01z"],"right":["M1183.25 947.53c2.57 14.85 4.32 31.11 6.22 46.14q.35 2.74-1.11.39c-14.67-23.67-23.34-52.15-30.55-79.32q-5.08-19.14-5.97-39.05-1.36-30.37-2.44-60.74c-.22-6.09-2.56-15.63-.55-21.57q5.87-17.35 18.96-31.07c10.77-11.28 10.17 46.55 10.16 48.97-.13 41.09-.45 74.18 1.91 110.07.57 8.75 1.88 17.53 3.37 26.18z","M1136.43 791.52q.27-.42.49.03c3.12 6.46 4.84 12.26 5.68 19.83 5.07 45.8 8.05 94.61 7.56 140.76-.13 11.8-.46 26.22-5.13 37.08a.44.44 0 01-.83-.06q-2.51-9.14-3.69-18.41-3.54-27.64-7.36-55.24c-2.49-18-5.47-35.67-11.09-52.26q-4.35-12.82-2.08-26.75c1.76-10.77 3.58-21.61 8.46-31.16q3.58-6.99 7.99-13.82z","M1115.03 856.73c2.03 18.72 7.11 37.44 11.47 55.77 2.25 9.46 3.94 19.51 3.95 30.11q.02 31.7 4.08 63.16.16 1.26-.29.07-2.7-7.15-4.19-14.6c-4.44-22.21-5.71-40.52-6.87-61.23-.24-4.24-1.19-9.64-2.23-13.92q-3.94-16.25-7.7-32.55c-2.09-9.04.08-18.69 1.6-27.66q.07-.38.32-.09.16.19.01.4-.19.24-.15.54z","M1202.61 741.08a.44.44 0 01.72.03c.52.82.9 1.86.95 2.91q.73 15.98.37 31.97-1.16 52.95-7.85 105.49-1.88 14.74-5.97 29.04-1 3.52-1.92 4.95-1.57 2.47-1.39-.37c.58-9.44 1.83-19.17 1.71-28.16-.32-24.52-4.94-49.11-3.95-72.75.69-16.54 2.5-33.51 7.54-49.38q2.99-9.4 6.61-18.6.74-1.88 3.18-5.13z"]},"calves":{"left":["M982.69 1149.31c-3.07-2.23-3.98-6.24-5.24-11.03-7.19-27.14-7.88-53.18-6.67-82.78q1.03-25.29 9.23-47.45c4.77-12.89 15.33-24.77 23.79-36q.82-1.09.74.27c-1.37 22.86-2.72 45.67-3.11 68.49-.52 30.56-1.51 61.11-.42 91.68.24 6.83-2.77 16.29-10.08 18.37q-4.39 1.25-8.24-1.55z","M983.99 1163.56c7.15-5.59 16.16-.63 17 8.23q4.31 45.02 5.22 90.26c.16 8.25-.8 15.79-2.19 23.65q-.45 2.52-1.43 3.66-.95 1.11-1.22-.33c-5.03-26.7-8.28-53.49-11.87-80.36q-1.68-12.52-3.24-18.71-2.04-8.12-5.53-18.24c-1.03-3 .8-6.25 3.26-8.16z","M1013.69 1150.31c-4.8-2.61-4.66-16.17-4.36-20.75 2.34-36.49 3.44-73.94 1.04-110.45-1.03-15.55.02-31.49.62-47.06q.03-.66.25-.03c2.28 6.45 4.52 12.88 7.39 19.11 5.12 11.14 11.5 22.91 14.83 33.92q2.34 7.74 3.97 16.46 5.3 28.43 5.62 56.09c.2 18.32-7.9 40-22.63 51.79q-3.42 2.73-6.73.92z","M1014.14 1164.37c7-1.83 14.1 2.2 14.11 9.95q.06 29.04-5.62 57.41c-3.87 19.28-6.24 38.23-8.43 57.48a.37.37 0 01-.74-.01q-3.12-43.48-3.58-86.64-.15-14.16.76-28.3c.18-2.83.02-8.98 3.5-9.89z"],"right":["M1172.94 1149.31c-6.06-4.56-6.94-11.4-6.8-19.4.96-52.67-.49-105.31-3.54-157.9q-.04-.72.41-.16 7.96 10.07 15.43 20.44c9.11 12.64 13.61 28.98 15.78 44.21 4.96 34.71 3.75 72.94-5.97 106.5-1.97 6.82-9.18 10.93-15.31 6.31z","M1144.41 1147.33q-17.19-17.37-20.08-40.86-.89-7.22-.13-19.97 1.18-20.06 4.69-41.33c2.33-14.1 5.8-25.22 12.41-38.61q8.19-16.59 14.35-34.15a.14.13-37.7 01.26.03q1.01 15.71 1.26 31.44c.18 11.61-1.34 24.91-1.58 36.43-.72 34.7 1.22 62.05 2.06 93.19.17 6.32-1.1 26.1-13.24 13.83z","M1173.74 1161.73c6.88-2 14.34 3.23 11.98 10.91-2.24 7.3-4.78 14.44-5.99 21.96-5.07 31.52-8.04 63.18-14.13 94.6a.72.71-61.9 01-1.21.37c-.14-.14-.35-.39-.4-.59q-3.53-13.58-3.19-28.23 1.04-44.67 5.06-87.04c.58-6.1 1.93-10.25 7.88-11.98z","M1154.32 1165a1.58 1.57-84.6 01.97 1.18c.79 4.42 1.42 8.78 1.57 13.4.96 29.17-.47 62.66-2.04 90.23q-.78 13.79-1.39 19.52a.23.23 0 01-.45 0c-2.79-21.25-5.41-41.99-9.64-63.03-3.44-17.08-4.29-34.91-4.68-52.3-.19-8.37 8.99-11.61 15.66-9z"]},"ankles":{"left":["M998.25 1320.52c-4.62.24-8.17-1.08-8.78-6.28-1.6-13.81-.75-28.85-2.16-42.41q-.39-3.74.24-7.03a.69.69 0 011.23-.28c2.35 3.15 4.22 5.75 5.14 9.66 1.54 6.57 1.91 22.57 9.97 24.09q13.33 2.5 15.93-10.47c.92-4.57 1-12.33 5.05-17.25q.42-.51.42.15c.11 14.39.4 30.86-3.08 44.54-.79 3.13-3.31 4.23-6.51 4.4q-8.73.45-17.45.88z"],"right":["M1149.5 1319.51c-6.93-.63-6.82-18.08-7.14-23.7q-.73-12.53-.59-25.09.01-.71.45-.15 2.74 3.49 3.29 7.17c1.67 11.25 3.21 25.34 19.7 19.99 4.87-1.58 7.03-18.57 7.89-23.21.79-4.2 2.74-7 5.28-10.13a.56.56 0 01.98.22c1.12 4.6.04 12.39-.37 17.26-.92 10.77-.32 21.48-1.52 32.37q-.7 6.23-7.01 6.18-12.13-.11-20.96-.91z"]},"feet":{"left":["M962.87 1327.38q-.62-.51-.05-1.07l1.99-1.99q.39-.39.93-.41 25.66-.82 51.26 1 1.34.1 4.43 1.47.46.2.69.64 1.84 3.5 2.87 7.23c2.32 8.38-6.63 7.24-12.23 6.68q-15.37-1.53-30.5-4.56c-8.21-1.65-13.33-3.95-19.39-8.99z"],"right":["M1154.35 1341.35c-12.48 1.36-13.27-3.88-8.67-13.37 1.82-3.76 12.72-3.65 16.39-3.77q19.44-.63 38.9-.44c2.41.02 3.31 1 4.61 2.76q.32.44-.09.79c-5.43 4.67-10.52 7.17-17.95 8.74q-16.46 3.47-33.19 5.29z"]},"hands":{"left":["M789.41 726.84c3.98-6.79 9.89-14.6 16.56-20.14a.31.31 0 01.48.35c-4.39 11.06-5.38 21.94-14.02 30.72-5.82 5.93-10.7 9.81-19.04 8.57q-.55-.08-.59-.63c-.24-3.07-.26-7.29 3.1-8.85 4.82-2.26 10.72-5.28 13.51-10.02z","M807.27 745.31c17.61 3.49 2.75 13.52-.73 18.99q-10.05 15.82-21.86 30.37-1.56 1.92-2.52-.58a2.41 2.33-55.4 01-.16-.96q.2-5.26 2.75-9.71c6.94-12.09 13.12-24.52 19.72-36.79q.91-1.7 2.8-1.32z","M819.3 744.82c-7.79-6.06-14.51-12.4-11.88-23.38 3.07-12.83 14.66-20.7 25.14-26.38 9.57-5.18 37.61-.75 37.6 13.68q-.01 16.24-3.67 31.99c-2.38 10.26-4.49 16.44-16.87 16.3-10.71-.13-21.93-5.7-30.32-12.21z","M827.99 758.27a2.08 2.07 26.6 01.91 2.73q-10.47 22.03-19.66 45.04-2.25 5.63-8.23 6.74a1.45 1.44 84.3 01-1.7-1.4q-.1-4.29 1.51-8.31 7.3-18.34 13.86-36.96c.74-2.1 1.53-6.08 2.97-8.96q.26-.5.82-.57 5.05-.64 9.52 1.69z","M841.68 762.32a.76.75-79.1 01.6.89q-4.51 23.14-9.28 45.87c-.73 3.49-2.09 5.73-5.85 5.43q-.52-.04-.61-.56-.74-4.54-.32-7.21 2.89-18.57 5.59-37.18.38-2.65 1.67-8.22.13-.54.68-.44l7.52 1.42z","M854.75 799.53a.78.78 0 01-1.37-.02q-.91-1.75-1.15-4.29-1.62-16.58-1.2-33.25a.84.84 0 01.61-.78l7.09-1.93q.59-.16.56.45-.58 14.77-1.12 29.56c-.14 4.06-1.54 6.86-3.42 10.26z"],"right":["M1336.39 751.96c-8.72 4.49-29.38 10.28-33.61-3.6q-5.68-18.65-5.83-38.24c-.06-7.59 4.01-11.75 11.09-14.08 8.85-2.92 19.02-5.3 27.54-.35 8.74 5.09 18.39 11.28 22.45 21.01 3.05 7.3 3.34 13.66-1.78 20.01-5.21 6.47-12.49 11.45-19.86 15.25z","M1374.32 737.5c-8.05-8.14-9.61-19.67-13.85-30.75a.22.22 0 01.35-.24q10.3 8.96 17.1 20.77c2.57 4.47 9.08 7.59 13.57 9.79 3.11 1.52 2.96 5.9 2.71 8.73q-.05.52-.57.59c-8.87 1.17-13.48-2.98-19.31-8.89z","M1383.76 795.45c-.59-.21-.96-.17-1.39-.68-8.84-10.3-15.85-21.5-23.44-32.41-2.81-4.02-8.81-7.64-7.45-13.14q.15-.6.7-.84l7.85-3.44q.66-.29 1.13.25 2.36 2.73 4.17 6.49 7.36 15.23 16.89 31.47c2.33 3.96 3.04 7.59 2.32 11.85a.58.58 0 01-.78.45z","M1365.79 812.62c-2.7-.28-6.42-2.66-7.49-5.33q-8.74-21.76-19.85-45.74c-2.12-4.58 6.55-5.17 9.12-5.21 1.8-.03 1.93.71 2.38 2.18q5.72 18.34 15.35 42.12c.74 1.84 4.81 12.43.49 11.98z","M1308.16 759.17l7.44 2.1q.23.07.24.31.75 16.26-.86 32.41-.3 3-1.25 5.48a.79.79 0 01-1.42.12q-3.9-6.58-3.82-13.9.16-13.07-.83-26.11-.05-.57.5-.41z","M1340.07 814.35c-2.7.82-4.99-1.16-5.54-3.71q-5.06-23.49-9.82-47.47a.77.76-10.7 01.62-.9l7.52-1.38q.59-.11.73.47c2.08 8.53 3.26 19.85 4.22 25.75q2.09 12.92 3.19 21.14.34 2.54-.33 5.46a.86.84 88.4 01-.59.64z"]},"head":{"left":[],"right":[]},"hair":{"left":[],"right":[]}}} \ No newline at end of file diff --git a/app/src/main/assets/ironlog/default_plan.json b/app/src/main/assets/ironlog/default_plan.json new file mode 100644 index 0000000..50e1443 --- /dev/null +++ b/app/src/main/assets/ironlog/default_plan.json @@ -0,0 +1,263 @@ +{ + "id": "aesthetic-split-default", + "name": "Aesthetic Split", + "description": "4 days, cutting phase, focused on lats, side delts, abs, and legs", + "days": [ + { + "id": "push", + "label": "DAY 1", + "name": "PUSH", + "tag": "Chest · Shoulders · Triceps", + "color": "#FF4500", + "exercises": [ + { + "name": "Incline Smith Press", + "sets": 4, + "reps": "8–10", + "isHeavy": true, + "muscleGroup": "Upper Chest", + "notes": "Full stretch at bottom, squeeze at top." + }, + { + "name": "Cable Fly Low to High", + "sets": 3, + "reps": "12–15", + "muscleGroup": "Upper Chest", + "notes": "Cables at lowest point, pull upward in arc." + }, + { + "name": "Cable Lateral Raise Single Arm", + "sets": 4, + "reps": "15", + "muscleGroup": "Side Delts", + "notes": "3-second negative. Lead with elbow." + }, + { + "name": "DB Lateral Raise", + "sets": 3, + "reps": "15–20", + "muscleGroup": "Side Delts", + "notes": "Slight forward lean. Elbow leads." + }, + { + "name": "Rope Pushdown", + "sets": 3, + "reps": "12", + "muscleGroup": "Triceps", + "notes": "Flare rope at bottom. Elbows pinned." + }, + { + "name": "Single Arm Overhead Extension", + "sets": 3, + "reps": "12", + "muscleGroup": "Triceps", + "notes": "Long head stretch. Slow eccentric." + }, + { + "name": "Weighted Cable Crunch", + "sets": 4, + "reps": "15–20", + "muscleGroup": "Abs", + "notes": "Crunch into hips not knees." + } + ] + }, + { + "id": "pull", + "label": "DAY 2", + "name": "PULL", + "tag": "Lats · Upper Traps · Biceps", + "color": "#0080FF", + "exercises": [ + { + "name": "Weighted Pull-Up", + "sets": 4, + "reps": "6–8", + "isHeavy": true, + "muscleGroup": "Lats", + "notes": "Best lat builder. Add weight progressively." + }, + { + "name": "Single Arm DB Row", + "sets": 4, + "reps": "10–12", + "muscleGroup": "Lats", + "notes": "Incline bench 30 degrees. Pull elbow back." + }, + { + "name": "Single Arm Cable Pulldown", + "sets": 3, + "reps": "12", + "muscleGroup": "Lats", + "notes": "Lie chest-down on incline bench." + }, + { + "name": "DB Shrugs", + "sets": 4, + "reps": "15–20", + "isHeavy": true, + "muscleGroup": "Upper Traps", + "notes": "1-second hold at top." + }, + { + "name": "Hammer Curl", + "sets": 3, + "reps": "12", + "muscleGroup": "Biceps", + "notes": "Brachialis = thicker arm silhouette." + }, + { + "name": "Incline DB Curl", + "sets": 3, + "reps": "10", + "muscleGroup": "Biceps", + "notes": "Full long head stretch. Builds the peak." + }, + { + "name": "Hanging Leg Raise", + "sets": 4, + "reps": "12–15", + "muscleGroup": "Abs", + "notes": "Add ankle weights when easy." + } + ] + }, + { + "id": "legs", + "label": "DAY 3", + "name": "LEGS", + "tag": "Strength · Mobility · Dance and Run", + "color": "#00C170", + "exercises": [ + { + "name": "Hip 90/90 Stretch", + "sets": 2, + "reps": "60s each side", + "isWarmup": true, + "muscleGroup": "Hip Mobility", + "notes": "Non-negotiable warmup." + }, + { + "name": "Worlds Greatest Stretch", + "sets": 2, + "reps": "5 reps each side", + "isWarmup": true, + "muscleGroup": "Full Chain", + "notes": "T-spine plus hip flexor plus hamstring." + }, + { + "name": "Bulgarian Split Squat", + "sets": 4, + "reps": "8–10 each leg", + "isHeavy": true, + "muscleGroup": "Quads Glutes", + "notes": "Front shin vertical. Go deep." + }, + { + "name": "Romanian Deadlift", + "sets": 4, + "reps": "10", + "isHeavy": true, + "muscleGroup": "Hamstrings", + "notes": "Hip hinge equals running power." + }, + { + "name": "Leg Press High Feet", + "sets": 3, + "reps": "12", + "muscleGroup": "Glutes Hamstrings", + "notes": "High foot placement shifts load." + }, + { + "name": "Lateral Band Walk", + "sets": 3, + "reps": "15 steps each way", + "muscleGroup": "Glute Med", + "notes": "Lateral stability for cutting." + }, + { + "name": "Single Leg Calf Raise", + "sets": 4, + "reps": "20", + "muscleGroup": "Calves", + "notes": "On a step for full ROM." + }, + { + "name": "Ankle Mobility Drill", + "sets": 2, + "reps": "60s each side", + "isWarmup": true, + "muscleGroup": "Ankles", + "notes": "Cooldown. Do not skip." + } + ] + }, + { + "id": "upper", + "label": "DAY 4", + "name": "UPPER", + "tag": "Aesthetic Focus · Arms · Abs", + "color": "#A020F0", + "exercises": [ + { + "name": "Weighted Pull-Up or Lat Pulldown", + "sets": 4, + "reps": "8", + "isHeavy": true, + "muscleGroup": "Lats", + "notes": "Second lat session. Full stretch." + }, + { + "name": "Cable Lateral Raise Single Arm", + "sets": 4, + "reps": "15", + "muscleGroup": "Side Delts", + "notes": "Second hit this week. 3s negative." + }, + { + "name": "DB Shrugs", + "sets": 3, + "reps": "20", + "isHeavy": true, + "muscleGroup": "Upper Traps", + "notes": "Push weight up from Pull day." + }, + { + "name": "Preacher Curl", + "sets": 3, + "reps": "10–12", + "muscleGroup": "Biceps", + "notes": "No cheating possible." + }, + { + "name": "Rope Overhead Extension", + "sets": 3, + "reps": "12", + "muscleGroup": "Triceps", + "notes": "Long head. Makes arms look big." + }, + { + "name": "Weighted Cable Crunch", + "sets": 4, + "reps": "15–20", + "muscleGroup": "Abs", + "notes": "Heavier than Push day." + }, + { + "name": "Hanging Leg Raise", + "sets": 4, + "reps": "15", + "muscleGroup": "Abs", + "notes": "Add ankle weights." + }, + { + "name": "Ab Wheel Rollout", + "sets": 3, + "reps": "10", + "muscleGroup": "Full Core", + "notes": "Hardest ab exercise." + } + ] + } + ] +} \ No newline at end of file diff --git a/app/src/main/assets/ironlog/exercise_filter_rules.json b/app/src/main/assets/ironlog/exercise_filter_rules.json new file mode 100644 index 0000000..d4923c5 --- /dev/null +++ b/app/src/main/assets/ironlog/exercise_filter_rules.json @@ -0,0 +1,760 @@ +{ + "MUSCLE_FILTER_OPTIONS": [ + "Chest", + "Upper Chest", + "Mid Chest", + "Lower Chest", + "Back", + "Lats", + "Upper Back", + "Traps", + "Spinal Erectors", + "Shoulders", + "Front Delts", + "Side Delts", + "Rear Delts", + "Rotator Cuff", + "Arms", + "Biceps", + "Biceps Long Head", + "Biceps Short Head", + "Brachialis", + "Triceps", + "Triceps Long Head", + "Triceps Lateral Head", + "Triceps Medial Head", + "Forearms", + "Core", + "Upper Abs", + "Lower Abs", + "Obliques", + "Serratus", + "Legs", + "Quads", + "Hamstrings", + "Glutes", + "Calves", + "Adductors", + "Abductors" + ], + "RULES": [ + { + "patterns": [ + "serratus" + ], + "tags": [ + "Chest", + "Serratus" + ] + }, + { + "patterns": [ + "upper chest", + "clavicular chest" + ], + "tags": [ + "Chest", + "Upper Chest" + ] + }, + { + "patterns": [ + "mid chest", + "middle chest" + ], + "tags": [ + "Chest", + "Mid Chest" + ] + }, + { + "patterns": [ + "lower chest", + "sternal chest" + ], + "tags": [ + "Chest", + "Lower Chest" + ] + }, + { + "patterns": [ + "pec", + "pectoral", + "chest" + ], + "tags": [ + "Chest" + ] + }, + { + "patterns": [ + "lats", + "lat" + ], + "tags": [ + "Back", + "Lats" + ] + }, + { + "patterns": [ + "upper back", + "middle back", + "mid back", + "rhomboid" + ], + "tags": [ + "Back", + "Upper Back" + ] + }, + { + "patterns": [ + "trap", + "trapezius" + ], + "tags": [ + "Back", + "Traps" + ] + }, + { + "patterns": [ + "lower back", + "spinal erector", + "erector" + ], + "tags": [ + "Back", + "Spinal Erectors" + ] + }, + { + "patterns": [ + "back" + ], + "tags": [ + "Back" + ] + }, + { + "patterns": [ + "front delt", + "anterior delt", + "shoulder front", + "shoulder - front" + ], + "tags": [ + "Shoulders", + "Front Delts" + ] + }, + { + "patterns": [ + "side delt", + "lateral delt", + "medial delt", + "shoulder side", + "shoulder - side" + ], + "tags": [ + "Shoulders", + "Side Delts" + ] + }, + { + "patterns": [ + "rear delt", + "posterior delt", + "shoulder back", + "shoulder - back" + ], + "tags": [ + "Shoulders", + "Rear Delts" + ] + }, + { + "patterns": [ + "rotator cuff", + "external shoulder rotation", + "internal shoulder rotation" + ], + "tags": [ + "Shoulders", + "Rotator Cuff" + ] + }, + { + "patterns": [ + "shoulder", + "deltoid", + "delt" + ], + "tags": [ + "Shoulders" + ] + }, + { + "patterns": [ + "biceps long head", + "long head biceps" + ], + "tags": [ + "Arms", + "Biceps", + "Biceps Long Head" + ] + }, + { + "patterns": [ + "biceps short head", + "short head biceps" + ], + "tags": [ + "Arms", + "Biceps", + "Biceps Short Head" + ] + }, + { + "patterns": [ + "brachialis" + ], + "tags": [ + "Arms", + "Biceps", + "Brachialis" + ] + }, + { + "patterns": [ + "biceps", + "bicep" + ], + "tags": [ + "Arms", + "Biceps" + ] + }, + { + "patterns": [ + "triceps long head", + "long head triceps" + ], + "tags": [ + "Arms", + "Triceps", + "Triceps Long Head" + ] + }, + { + "patterns": [ + "triceps lateral", + "lateral head triceps" + ], + "tags": [ + "Arms", + "Triceps", + "Triceps Lateral Head" + ] + }, + { + "patterns": [ + "triceps medial", + "medial head triceps" + ], + "tags": [ + "Arms", + "Triceps", + "Triceps Medial Head" + ] + }, + { + "patterns": [ + "triceps", + "tricep" + ], + "tags": [ + "Arms", + "Triceps" + ] + }, + { + "patterns": [ + "forearm", + "wrist", + "hand" + ], + "tags": [ + "Arms", + "Forearms" + ] + }, + { + "patterns": [ + "upper abs" + ], + "tags": [ + "Core", + "Upper Abs" + ] + }, + { + "patterns": [ + "lower abs" + ], + "tags": [ + "Core", + "Lower Abs" + ] + }, + { + "patterns": [ + "oblique" + ], + "tags": [ + "Core", + "Obliques" + ] + }, + { + "patterns": [ + "abdominal", + "abs", + "core" + ], + "tags": [ + "Core" + ] + }, + { + "patterns": [ + "quad", + "quadriceps" + ], + "tags": [ + "Legs", + "Quads" + ] + }, + { + "patterns": [ + "hamstring" + ], + "tags": [ + "Legs", + "Hamstrings" + ] + }, + { + "patterns": [ + "glute", + "gluteus" + ], + "tags": [ + "Legs", + "Glutes" + ] + }, + { + "patterns": [ + "calf", + "tibialis" + ], + "tags": [ + "Legs", + "Calves" + ] + }, + { + "patterns": [ + "adductor", + "thigh inner", + "groin" + ], + "tags": [ + "Legs", + "Adductors" + ] + }, + { + "patterns": [ + "abductor", + "thigh outer", + "hip abduction" + ], + "tags": [ + "Legs", + "Abductors" + ] + }, + { + "patterns": [ + "legs" + ], + "tags": [ + "Legs" + ] + } + ], + "NAME_HINTS": [ + { + "patterns": [ + "incline bench", + "incline press", + "incline fly" + ], + "tags": [ + "Chest", + "Upper Chest" + ] + }, + { + "patterns": [ + "decline bench", + "decline press" + ], + "tags": [ + "Chest", + "Lower Chest" + ] + }, + { + "patterns": [ + "bench press", + "chest press", + "push up", + "push-up" + ], + "tags": [ + "Chest" + ] + }, + { + "patterns": [ + "face pull", + "reverse fly", + "rear delt row" + ], + "tags": [ + "Shoulders", + "Rear Delts", + "Back", + "Upper Back" + ] + }, + { + "patterns": [ + "shrug" + ], + "tags": [ + "Back", + "Traps" + ] + }, + { + "patterns": [ + "pullover" + ], + "tags": [ + "Back", + "Lats", + "Chest" + ] + }, + { + "patterns": [ + "lat pulldown", + "pull-up", + "pull up", + "chin-up", + "chin up" + ], + "tags": [ + "Back", + "Lats", + "Arms", + "Biceps" + ] + }, + { + "patterns": [ + "high row", + "t-bar", + "row" + ], + "tags": [ + "Back", + "Upper Back" + ] + }, + { + "patterns": [ + "upright row" + ], + "tags": [ + "Back", + "Traps", + "Shoulders", + "Side Delts" + ] + }, + { + "patterns": [ + "lateral raise" + ], + "tags": [ + "Shoulders", + "Side Delts" + ] + }, + { + "patterns": [ + "front raise" + ], + "tags": [ + "Shoulders", + "Front Delts" + ] + }, + { + "patterns": [ + "curl" + ], + "tags": [ + "Arms", + "Biceps" + ] + }, + { + "patterns": [ + "hammer curl" + ], + "tags": [ + "Arms", + "Biceps", + "Brachialis", + "Forearms" + ] + }, + { + "patterns": [ + "pushdown" + ], + "tags": [ + "Arms", + "Triceps", + "Triceps Lateral Head", + "Triceps Medial Head" + ] + }, + { + "patterns": [ + "overhead tricep extension", + "overhead triceps extension", + "skull crusher" + ], + "tags": [ + "Arms", + "Triceps", + "Triceps Long Head" + ] + }, + { + "patterns": [ + "wood chop", + "woodchop", + "twist", + "rotation" + ], + "tags": [ + "Core", + "Obliques" + ] + }, + { + "patterns": [ + "crunch" + ], + "tags": [ + "Core", + "Upper Abs" + ] + }, + { + "patterns": [ + "leg raise", + "hanging knee raise" + ], + "tags": [ + "Core", + "Lower Abs" + ] + }, + { + "patterns": [ + "deadlift", + "romanian deadlift", + "rdl" + ], + "tags": [ + "Legs", + "Hamstrings", + "Glutes", + "Back", + "Spinal Erectors" + ] + }, + { + "patterns": [ + "hip thrust", + "glute bridge", + "glute kickback" + ], + "tags": [ + "Legs", + "Glutes" + ] + }, + { + "patterns": [ + "belt squat", + "hack squat", + "leg press", + "squat" + ], + "tags": [ + "Legs", + "Quads" + ] + }, + { + "patterns": [ + "lunge", + "split squat" + ], + "tags": [ + "Legs", + "Quads", + "Glutes" + ] + }, + { + "patterns": [ + "hip adduction" + ], + "tags": [ + "Legs", + "Adductors" + ] + }, + { + "patterns": [ + "hip abduction", + "lateral walk" + ], + "tags": [ + "Legs", + "Abductors", + "Glutes" + ] + }, + { + "patterns": [ + "calf raise" + ], + "tags": [ + "Legs", + "Calves" + ] + } + ], + "CATEGORY_RULES": [ + { + "patterns": [ + "cardio", + "conditioning", + "distance", + "duration", + "metcon" + ], + "tag": "Cardio" + }, + { + "patterns": [ + "stretch", + "mobility" + ], + "tag": "Mobility" + }, + { + "patterns": [ + "olympic" + ], + "tag": "Olympic" + }, + { + "patterns": [ + "strength", + "weight", + "rep", + "powerlifting" + ], + "tag": "Strength" + } + ], + "EQUIPMENT_RULES": [ + { + "patterns": [ + "barbell", + "trap bar", + "landmine" + ], + "tag": "Barbell" + }, + { + "patterns": [ + "dumbbell" + ], + "tag": "Dumbbell" + }, + { + "patterns": [ + "cable" + ], + "tag": "Cable" + }, + { + "patterns": [ + "machine", + "smith" + ], + "tag": "Machine" + }, + { + "patterns": [ + "bodyweight", + "body only", + "ring", + "rings", + "dip bar", + "pull-up bar" + ], + "tag": "Bodyweight" + }, + { + "patterns": [ + "band" + ], + "tag": "Band" + }, + { + "patterns": [ + "kettlebell" + ], + "tag": "Kettlebell" + } + ] +} \ No newline at end of file diff --git a/app/src/main/assets/ironlog/exercise_id_map.json b/app/src/main/assets/ironlog/exercise_id_map.json new file mode 100644 index 0000000..807afe1 --- /dev/null +++ b/app/src/main/assets/ironlog/exercise_id_map.json @@ -0,0 +1,215 @@ +{ + "Barbell Bench Press": "Barbell_Bench_Press_-_Medium_Grip", + "Incline Barbell Bench Press": "Incline_Barbell_Bench_Press", + "DB Bench Press": "Dumbbell_Bench_Press", + "Incline DB Bench Press": "Incline_Dumbbell_Bench_Press", + "Cable Fly Low to High": null, + "Cable Fly": "Cable_Fly", + "Pec Deck": "Pec_Deck_Fly", + "Incline Smith Press": "Smith_Machine_Bench_Press", + "Push-Up": "Push-Up", + "Dips (Chest)": "Dips_-_Chest_Version", + "Pull-Up": "Pull-Up", + "Weighted Pull-Up": "Weighted_Pull_Ups", + "Chin-Up": "Chin-Up", + "Lat Pulldown": "Wide-Grip_Lat_Pulldown", + "Single Arm Cable Pulldown": "Close-Grip_Front_Lat_Pulldown", + "Barbell Row": "Barbell_Bent_Over_Row", + "Single Arm DB Row": "One-Arm_Dumbbell_Row", + "Seated Cable Row": "Seated_Cable_Rows", + "Face Pull": "Face_Pull", + "Straight Arm Pulldown": "Straight-Arm_Pulldown", + "Deadlift": "Barbell_Deadlift", + "Sumo Deadlift": "Sumo_Deadlift", + "Hyperextension": "Hyperextensions_With_No_Hyperextension_Bench", + "Meadows Row": null, + "Chest Supported Row": "Lying_T-Bar_Row", + "Barbell OHP": "Barbell_Shoulder_Press", + "DB OHP": "Dumbbell_Shoulder_Press", + "Arnold Press": "Arnold_Dumbbell_Press", + "Cable Lateral Raise": "Cable_Internal_Rotation", + "DB Lateral Raise": "Side_Lateral_Raise", + "Lateral Raise Machine": "Side_Lateral_Raise", + "Rear Delt Fly": "Bent_Over_Dumbbell_Rear_Lateral_Raise", + "Reverse Pec Deck": "Reverse_Flyes", + "DB Shrugs": "Dumbbell_Shrug", + "Barbell Shrugs": "Barbell_Shrug", + "Barbell Curl": "Barbell_Curl", + "EZ Bar Curl": "EZ-Bar_Curl", + "DB Curl": "Dumbbell_Alternate_Bicep_Curl", + "Hammer Curl": "Hammer_Curls", + "Incline DB Curl": "Incline_Dumbbell_Curl", + "Concentration Curl": "Concentration_Curls", + "Preacher Curl": "Preacher_Curl", + "Cable Curl": "Low_Cable_Curl", + "Spider Curl": "Spider_Curl", + "Zottman Curl": "Zottman_Curl", + "Close Grip Bench Press": "Close-Grip_Barbell_Bench_Press", + "Skull Crusher": "Barbell_Skullcrusher", + "Rope Pushdown": "Triceps_Pushdown_-_Rope_Attachment", + "Single Arm Pushdown": "Triceps_Pushdown", + "Rope Overhead Extension": "Cable_Rope_Overhead_Triceps_Extension", + "Single Arm OHE": "Overhead_Triceps", + "Dips (Triceps)": "Triceps_Dips", + "Diamond Push-Up": "Close-Grip_Push-Up_off_of_a_Dumbbell", + "Back Squat": "Barbell_Full_Squat", + "Front Squat": "Front_Barbell_Squat", + "Bulgarian Split Squat": null, + "Romanian Deadlift": "Romanian_Deadlift", + "Leg Press": "Leg_Press", + "Leg Press High Feet": "Leg_Press", + "Hack Squat": "Hack_Squat", + "Leg Extension": "Leg_Extensions", + "Leg Curl Machine": "Seated_Leg_Curl", + "Nordic Curl": "Natural_Glute_Ham_Raise", + "Hip Thrust": "Barbell_Hip_Thrust", + "Walking Lunge": "Barbell_Walking_Lunge", + "Step Up": "Barbell_Step_Ups", + "Lateral Band Walk": null, + "Single Leg Calf Raise": "Standing_Calf_Raises", + "Standing Calf Raise": "Standing_Calf_Raises", + "Seated Calf Raise": "Seated_Calf_Raise", + "Weighted Cable Crunch": "Cable_Crunch", + "Hanging Leg Raise": "Hanging_Leg_Raise", + "Ab Wheel Rollout": "Barbell_Ab_Rollout", + "Plank": "Plank", + "Side Plank": "Side_Plank", + "Dragon Flag": "Dragon_Flag", + "L-Sit": null, + "Toes to Bar": null, + "Russian Twist": "Russian_Twist", + "Dead Bug": "Dead_Bug", + "V-Up": "V_Up", + "Hip 90/90 Stretch": null, + "World's Greatest Stretch": "Worlds_Greatest_Stretch", + "Ankle Mobility Drill": null, + "Cat Cow": null, + "Couch Stretch": null, + "Deep Squat Hold": null, + "Incline Treadmill Walk": null, + "Running": null, + "Weighted Pull-Up or Lat Pulldown": "Wide-Grip_Lat_Pulldown", + "Pullups": "Pull-Up", + "Pushups": "Push-Up", + "Seated Cable Rows": "Seated_Cable_Rows", + "Leg Extensions": "Leg_Extensions", + "Hammer Curls": "Hammer_Curls", + "Weighted Pull Ups": "Weighted_Pull_Ups", + "Worlds Greatest Stretch": "Worlds_Greatest_Stretch", + "Bench Press - Smith Machine": "Smith_Machine_Bench_Press", + "Smith Machine Bench Press": "Smith_Machine_Bench_Press", + "Squat - Smith Machine": "Smith_Machine_Squat", + "Smith Machine Squat": "Smith_Machine_Squat", + "Hack Squat - Barbell": "Barbell_Hack_Squat", + "Barbell Rear Delt Row": "Rear_Delt_Row_-_Barbell", + "Cable Lateral Raise (Single Arm)": "Cable_Internal_Rotation", + "Machine Lateral Raise": "Side_Lateral_Raise", + "Assisted Pull-Up Machine": "Assisted_Pull-Up", + + "Overhead Press": "Barbell_Shoulder_Press", + "Military Press": "Barbell_Shoulder_Press", + "Barbell Overhead Press": "Barbell_Shoulder_Press", + "Barbell Shoulder Press": "Barbell_Shoulder_Press", + "Shoulder Press": "Dumbbell_Shoulder_Press", + "Dumbbell Overhead Press": "Dumbbell_Shoulder_Press", + "DB Overhead Press": "Dumbbell_Shoulder_Press", + "Seated Dumbbell Press": "Dumbbell_Shoulder_Press", + "Seated DB Press": "Dumbbell_Shoulder_Press", + "Dumbbell Shoulder Press": "Dumbbell_Shoulder_Press", + + "Dumbbell Bench Press": "Dumbbell_Bench_Press", + "Flat Dumbbell Press": "Dumbbell_Bench_Press", + "Flat Dumbbell Bench Press": "Dumbbell_Bench_Press", + "Flat Bench Press": "Barbell_Bench_Press_-_Medium_Grip", + "BB Bench Press": "Barbell_Bench_Press_-_Medium_Grip", + "Bench Press": "Barbell_Bench_Press_-_Medium_Grip", + "Incline Dumbbell Press": "Incline_Dumbbell_Bench_Press", + "Incline Dumbbell Bench Press": "Incline_Dumbbell_Bench_Press", + "Incline DB Press": "Incline_Dumbbell_Bench_Press", + "Incline Press": "Incline_Barbell_Bench_Press", + "Chest Fly": "Pec_Deck_Fly", + "Dumbbell Fly": "Pec_Deck_Fly", + "Pec Fly": "Pec_Deck_Fly", + "Cable Crossover": "Cable_Fly", + + "Bent Over Row": "Barbell_Bent_Over_Row", + "Bent-Over Row": "Barbell_Bent_Over_Row", + "Bent Over Barbell Row": "Barbell_Bent_Over_Row", + "BB Row": "Barbell_Bent_Over_Row", + "Dumbbell Row": "One-Arm_Dumbbell_Row", + "One Arm Dumbbell Row": "One-Arm_Dumbbell_Row", + "Single-Arm Dumbbell Row": "One-Arm_Dumbbell_Row", + "Single Arm Row": "One-Arm_Dumbbell_Row", + "DB Row": "One-Arm_Dumbbell_Row", + "Cable Row": "Seated_Cable_Rows", + "Machine Row": "Seated_Cable_Rows", + "Seated Row": "Seated_Cable_Rows", + "Low Cable Row": "Seated_Cable_Rows", + "Pulldown": "Wide-Grip_Lat_Pulldown", + "Pull Down": "Wide-Grip_Lat_Pulldown", + "Lat Pulldown (Wide Grip)": "Wide-Grip_Lat_Pulldown", + "Cable Lat Pulldown": "Wide-Grip_Lat_Pulldown", + "Machine Lat Pulldown": "Wide-Grip_Lat_Pulldown", + "T-Bar Row": "Lying_T-Bar_Row", + "Back Extension": "Hyperextensions_With_No_Hyperextension_Bench", + "Hyperextension": "Hyperextensions_With_No_Hyperextension_Bench", + "Deadlifts": "Barbell_Deadlift", + "Barbell Deadlift": "Barbell_Deadlift", + "Straight Leg Deadlift": "Romanian_Deadlift", + "Romanian Deadlifts": "Romanian_Deadlift", + "RDL": "Romanian_Deadlift", + + "Lateral Raise": "Side_Lateral_Raise", + "Dumbbell Lateral Raise": "Side_Lateral_Raise", + "DB Lateral Raise (Cable)": "Cable_Internal_Rotation", + "Rear Delt Row": "Rear_Delt_Row_-_Barbell", + "Rear Delt Raise": "Bent_Over_Dumbbell_Rear_Lateral_Raise", + "Reverse Fly": "Reverse_Flyes", + "Reverse Dumbbell Fly": "Reverse_Flyes", + + "Dumbbell Curl": "Dumbbell_Alternate_Bicep_Curl", + "Dumbbell Bicep Curl": "Dumbbell_Alternate_Bicep_Curl", + "Alternating Dumbbell Curl": "Dumbbell_Alternate_Bicep_Curl", + "Alternating DB Curl": "Dumbbell_Alternate_Bicep_Curl", + "Bicep Curl": "Dumbbell_Alternate_Bicep_Curl", + "Incline Bicep Curl": "Incline_Dumbbell_Curl", + "Incline Curl": "Incline_Dumbbell_Curl", + "Cable Bicep Curl": "Low_Cable_Curl", + "Skull Crushers": "Barbell_Skullcrusher", + "Tricep Pushdown": "Triceps_Pushdown_-_Rope_Attachment", + "Cable Tricep Pushdown": "Triceps_Pushdown_-_Rope_Attachment", + "Triceps Rope Pushdown": "Triceps_Pushdown_-_Rope_Attachment", + "Rope Tricep Pushdown": "Triceps_Pushdown_-_Rope_Attachment", + "Tricep Rope Pushdown": "Triceps_Pushdown_-_Rope_Attachment", + "Triceps Pushdown": "Triceps_Pushdown", + "Overhead Tricep Extension": "Cable_Rope_Overhead_Triceps_Extension", + "Overhead Triceps Extension": "Cable_Rope_Overhead_Triceps_Extension", + "Tricep Overhead Extension": "Cable_Rope_Overhead_Triceps_Extension", + "Cable Overhead Tricep Extension": "Cable_Rope_Overhead_Triceps_Extension", + "Rope Overhead Tricep Extension": "Cable_Rope_Overhead_Triceps_Extension", + "Tricep Extension": "Cable_Rope_Overhead_Triceps_Extension", + "Tricep Dips": "Triceps_Dips", + "Bench Dips": "Triceps_Dips", + "Close Grip Press": "Close-Grip_Barbell_Bench_Press", + "Close Grip Bench": "Close-Grip_Barbell_Bench_Press", + + "Squat": "Barbell_Full_Squat", + "Barbell Squat": "Barbell_Full_Squat", + "High Bar Squat": "Barbell_Full_Squat", + "Low Bar Squat": "Barbell_Full_Squat", + "Leg Curls": "Seated_Leg_Curl", + "Hamstring Curl": "Seated_Leg_Curl", + "Lying Leg Curl": "Seated_Leg_Curl", + "Hip Thrust (Barbell)": "Barbell_Hip_Thrust", + "Glute Bridge": "Barbell_Hip_Thrust", + "Barbell Glute Bridge": "Barbell_Hip_Thrust", + "Lunge": "Barbell_Walking_Lunge", + "Walking Lunges": "Barbell_Walking_Lunge", + "Calf Raise": "Standing_Calf_Raises", + "Calf Raises": "Standing_Calf_Raises", + + "Cable Crunch": "Cable_Crunch", + "Hanging Leg Raises": "Hanging_Leg_Raise", + "Ab Rollout": "Barbell_Ab_Rollout", + "Ab Wheel": "Barbell_Ab_Rollout" +} \ No newline at end of file diff --git a/app/src/main/assets/ironlog/exercise_library_additions.json b/app/src/main/assets/ironlog/exercise_library_additions.json new file mode 100644 index 0000000..63f85ab --- /dev/null +++ b/app/src/main/assets/ironlog/exercise_library_additions.json @@ -0,0 +1,1424 @@ +[ + { + "id": "longhaul_bench_press_smith_machine", + "name": "Bench Press - Smith Machine", + "force": "push", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Machine", + "primaryMuscles": [ + "chest", + "front delts" + ], + "primaryMuscle": "chest", + "secondaryMuscles": [ + "triceps" + ], + "instructions": [ + "Set up a bench under a Smith machine so that the bar is over your chest while lying on the bench.", + "Lie down on the bench with your feet flat on the floor and your back pressed against the bench.", + "Extend your arms and grip the bar with your hands slightly wider than shoulder-width apart, palms facing forward.", + "Unrack the bar by straightening your arms and lifting it off the rack.", + "Lower the bar slowly towards your chest, keeping your elbows wide.", + "Pause for a moment when the bar touches your chest.", + "Push the bar back up to the starting position by extending your arms fully.", + "Repeat the movement for the desired number of repetitions.", + "Once finished, carefully rack the bar back onto the Smith machine." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_calf_raise_leg_press_machine", + "name": "Calf Raise - Leg Press Machine", + "force": "push", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Machine", + "primaryMuscles": [ + "calves" + ], + "primaryMuscle": "calves", + "secondaryMuscles": [], + "instructions": [ + "Sit on the leg press machine and position your feet about hip-width apart on the lower part of the platform.", + "Ensure that only the balls of your feet and your toes are making contact with the platform, with your heels hanging off.", + "Straighten your legs to release the safety bars of the machine, keeping your knees slightly bent to avoid locking them.", + "Press through the balls of your feet to push the platform away, raising your heels as high as possible and contracting your calf muscles.", + "Pause at the top of the movement for a moment to maximize the contraction in the calves.", + "Slowly lower your heels back below the level of the platform to return to the starting position and stretch your calf muscles.", + "Repeat for the desired number of reps." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_close_grip_feet_up_bench_press_barbell", + "name": "Close-Grip Feet-Up Bench Press - Barbell", + "force": "push", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Barbell", + "primaryMuscles": [ + "triceps" + ], + "primaryMuscle": "triceps", + "secondaryMuscles": [ + "chest", + "front delts" + ], + "instructions": [ + "Lie flat on a bench with your feet up and your back pressed against the bench.", + "Grasp the barbell with your hands shoulder-width apart or just slightly narrower.", + "Unrack the barbell and hold it above your chest with your arms extended.", + "Lower the barbell to your chest, keeping your elbows close to your body.", + "Pause briefly at the bottom of the movement, then press the barbell back up to the starting position.", + "Repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_deadlift_trap_bar_high_handles", + "name": "Deadlift - Trap Bar High Handles", + "force": "pull", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Barbell", + "primaryMuscles": [ + "glutes", + "hamstrings", + "lower back" + ], + "primaryMuscle": "glutes", + "secondaryMuscles": [ + "calves", + "forearms", + "traps", + "quadriceps" + ], + "instructions": [ + "Stand in the center of a loaded trap bar with your feet about hip-width apart.", + "Bend at your hips and knees to lower your body, maintaining a flat back, to grasp the high handles of the trap bar.", + "Look forward, keeping your chest up, and straighten your back to prepare for the lift.", + "Drive through your heels to stand up straight, extending your hips and knees simultaneously while keeping the core engaged.", + "At the top of the movement, stand fully upright with your shoulders back.", + "Pause briefly at the top of the lift before initiating the return.", + "Lower the bar by bending at the hips first, then the knees, maintaining a flat back and controlled movement until the weights are back on the ground.", + "Reset your posture and prepare for the next repetition." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_deadlift_trap_bar_low_handles", + "name": "Deadlift - Trap Bar Low Handles", + "force": "pull", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Barbell", + "primaryMuscles": [ + "glutes", + "hamstrings", + "lower back" + ], + "primaryMuscle": "glutes", + "secondaryMuscles": [ + "calves", + "forearms", + "traps", + "quadriceps" + ], + "instructions": [ + "Stand in the center of a loaded trap bar with your feet about hip-width apart.", + "Bend at your hips and knees to lower your body, maintaining a flat back, to grasp the low handles of the trap bar.", + "Look forward, keeping your chest up, and straighten your back to prepare for the lift.", + "Drive through your heels to stand up straight, extending your hips and knees simultaneously while keeping the core engaged.", + "At the top of the movement, stand fully upright with your shoulders back and the bar close to your body.", + "Pause briefly at the top of the lift before initiating the return.", + "Lower the bar by bending at the hips first, then the knees, maintaining a flat back and controlled movement until the weights are back on the ground.", + "Reset your posture and prepare for the next repetition." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_external_shoulder_rotation_band", + "name": "External Shoulder Rotation - Band", + "force": "static", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Band", + "primaryMuscles": [ + "rotator cuff" + ], + "primaryMuscle": "rotator cuff", + "secondaryMuscles": [], + "instructions": [ + "Attach a resistance band to a stable object at waist height.", + "Stand perpendicular to the anchor point with your right arm farthest away from it, and your feet shoulder-width apart.", + "Reach across your body with your right hand and grab the band.", + "Create resistance in the band by bringing your right elbow close to your right side where it hangs naturally.", + "This starting position has you with your right forearm across the front of your body, at a 90-degree angle to your right upper-arm.", + "Keep your core engaged and your shoulders relaxed.", + "Slowly rotate your arms outward, away from your body, until your right hand is in line with your right shoulder.", + "Pause for a moment, then slowly return to the starting position.", + "Repeat for the desired number of repetitions and then switch sides." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_external_shoulder_rotation_cable", + "name": "External Shoulder Rotation - Cable", + "force": "static", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Cable", + "primaryMuscles": [ + "rotator cuff" + ], + "primaryMuscle": "rotator cuff", + "secondaryMuscles": [ + "forearms", + "rear delts" + ], + "instructions": [ + "Stand side-on to a cable machine, with the machine arm set at the middle height. Attach a single handle.", + "Grab the handle with the hand that's furthest from the machine, ensuring that your elbow is bent at a 90-degree angle.", + "Step away from the machine so that the weight is lifted slightly from its stack and there is tension on the cable.", + "Position your elbow so that it is close to your side and holding weight across your abdomen.", + "Slowly rotate your arm outward, pulling the handle away from the machine while keeping your elbow fixed at your side.", + "Rotate the arm until the hand as far as possible without releasing tension on your back rotator cuff and without compromising form.", + "Pause for a moment at the maximum rotation point.", + "Slowly return the handle to the starting position in a controlled manner without allowing the weight to pull your arm back too fast.", + "Repeat the exercise for the desired number of reps before switching sides to ensure both back rotator cuffs are worked evenly." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": [ + "Keep your feet shoulder-width apart for stable footing and engage your core. Keep your body straight and avoid rotating your hips or shoulders during the movement. Your forearm should be perpendicular to your torso throughout the exercise." + ], + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_glute_kickback_machine", + "name": "Glute Kickback - Machine", + "force": "push", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Machine", + "primaryMuscles": [ + "glutes" + ], + "primaryMuscle": "glutes", + "secondaryMuscles": [ + "hamstrings", + "adductors" + ], + "instructions": [ + "Adjust the machine to fit your body height", + "Position yourself in the machine with your hands on the handles and one ankle on the padding.", + "Keeping your core engaged, exhale and extend your leg back as far as possible while squeezing your glutes.", + "Inhale and slowly return your leg to the starting position.", + "Repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_hack_squat_barbell", + "name": "Hack Squat - Barbell", + "force": "push", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Barbell", + "primaryMuscles": [ + "quadriceps" + ], + "primaryMuscle": "quadriceps", + "secondaryMuscles": [ + "calves", + "forearms", + "glutes", + "lower back", + "adductors", + "traps" + ], + "instructions": [ + "Stand in front of the barbell with your feet shoulder-width apart. The barbell will be by the back of your calves.", + "Bend down and grip the barbell with an overhand grip, with your palms facing behind you.", + "Stand up straight, lifting the barbell off the ground and holding it behind your legs.", + "Keeping your back straight, lower your body by bending your knees and hips, as if you were sitting down on a chair, until your thighs are parallel to the ground.", + "Push through your heels to stand back up to the starting position.", + "Repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_hack_squat_landmine", + "name": "Hack Squat - Landmine", + "force": "push", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Barbell", + "primaryMuscles": [ + "glutes", + "quadriceps", + "adductors" + ], + "primaryMuscle": "glutes", + "secondaryMuscles": [], + "instructions": [ + "Place the end of a barbell into a landmine attachment or securely anchor it in a corner.", + "Stand with your feet shoulder-width apart, toes slightly turned out.", + "Hold the unattached end of the barbell with both hands, palms facing inwards. Lift the end up and over one shoulder while you turn away from the weight. Rest the barbell on your shoulder with the weight at your back.", + "Keep your core engaged, chest lifted, and back straight.", + "Lower your body by bending at the knees and hips, as if sitting back into a chair.", + "Continue lowering until your thighs are parallel to the ground or slightly below.", + "Push through your heels and extend your knees and hips to return to the starting position.", + "Repeat for the desired number of repetitions." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_hack_squat_machine", + "name": "Hack Squat - Machine", + "force": "push", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Machine", + "primaryMuscles": [ + "glutes", + "quadriceps", + "adductors" + ], + "primaryMuscle": "glutes", + "secondaryMuscles": [], + "instructions": [ + "Adjust the seat height and foot platform of the machine to your desired position.", + "Sit on the machine with your back against the pad and grasp the handles with an overhand grip.", + "Engage your core throughout the exercise.", + "Bend your knees and lower your body down, keeping your back straight and your heels on the foot platform.", + "Continue descending until your thighs are parallel to the foot platform or as low as your flexibility allows.", + "Push through your heels and extend your legs to return to the starting position.", + "Repeat for the desired number of repetitions." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_high_to_low_wood_chop_cable", + "name": "High to Low Wood Chop - Cable", + "force": "static", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Cable", + "primaryMuscles": [ + "obliques" + ], + "primaryMuscle": "obliques", + "secondaryMuscles": [ + "abdominals" + ], + "instructions": [ + "Attach a cable to a high pulley and select the desired weight.", + "Stand perpendicular to the cable with feet shoulder-width apart and knees slightly bent.", + "Grasp the handle with both hands and extend arms overhead, keeping them straight.", + "Engage core muscles and rotate torso diagonally downwards, pulling the cable down towards the opposite hip.", + "Bend knees and pivot back foot as needed to maintain balance and stability.", + "Pause briefly at the bottom of the movement, then reverse the motion and return to starting position.", + "Repeat for desired number of repetitions, then switch sides and repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_hip_abduction_band", + "name": "Hip Abduction - Band", + "force": "static", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Band", + "primaryMuscles": [ + "abductors" + ], + "primaryMuscle": "abductors", + "secondaryMuscles": [ + "glutes" + ], + "instructions": [ + "Start by placing an exercise band around both of your knees. Make sure the band is secure and does not slip during the exercise.", + "Sit on a bench with your feet hip-width apart and your toes pointing forward.", + "Exhale as you slowly press your knees outward, against the resistance of the exercise band.", + "Continue pressing until you feel a comfortable stretch in your outer hip and thigh muscles.", + "Pause for a brief moment at the outwardmost position, feeling the tension in the exercise band.", + "Inhale as you release the pressure and bring your knees back together, returning to the starting position.", + "Repeat for the desired number of repetitions." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_hip_abduction_machine", + "name": "Hip Abduction - Machine", + "force": "static", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Machine", + "primaryMuscles": [ + "abductors" + ], + "primaryMuscle": "abductors", + "secondaryMuscles": [ + "glutes" + ], + "instructions": [ + "Adjust the machine to fit your height and leg length.", + "Sit on the machine with your back against the backrest and your feet on the footrests.", + "Press your legs outward against the resistance of the machine.", + "With your knees at their farthest point, hold the weight for a moment before slowly releasing the resistance and returning to the starting position.", + "Repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_hip_adduction_machine", + "name": "Hip Adduction - Machine", + "force": "static", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Machine", + "primaryMuscles": [ + "adductors" + ], + "primaryMuscle": "adductors", + "secondaryMuscles": [], + "instructions": [ + "Adjust the seat height of the hip adduction machine so that your knees are at a 90-degree angle when seated.", + "Sit on the machine with your back against the backrest and your feet flat on the footrests.", + "Place your thighs against the thigh pads, just above your knees.", + "Grasp the handles on the sides of the machine for stability.", + "Engage your core and squeeze your inner thighs together as you exhale.", + "Hold the contraction for a second and then slowly release as you inhale.", + "Repeat the movement for the desired number of repetitions." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_horizontal_external_shoulder_rotation_dumbbell", + "name": "Horizontal External Shoulder Rotation - Dumbbell", + "force": "static", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Dumbbell", + "primaryMuscles": [ + "rotator cuff" + ], + "primaryMuscle": "rotator cuff", + "secondaryMuscles": [], + "instructions": [ + "Stand perpendicular to a bar set at a height just below shoulder level when standing.", + "Grip a dumbbell with your working hand, maintaining the 90-degree angle at the elbow.", + "Place the upper arm of your working side on the bar so that it is parallel to the ground and your elbow is bent at a 90-degree angle. Your forearm with dumbbell should be hanging vertically towards the floor.", + "Brace your core and keep your shoulder blades down and back to prevent them from rolling forward during the exercise.", + "Rotate your arm at the shoulder, lifting the dumbbell upwards while keeping your upper arm and elbow firmly in place on the bar.", + "Continue the rotation until your forearm is horizontal to the floor or as far as your shoulder flexibility allows without discomfort or compromising form.", + "Pause at the top of the movement, focusing on contracting the muscles around the shoulder.", + "Slowly reverse the motion, lowering the dumbbell back to the starting position with control to complete one repetition.", + "Perform the desired number of reps, then switch sides and repeat the exercise with the other arm." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_horizontal_internal_shoulder_rotation_dumbbell", + "name": "Horizontal Internal Shoulder Rotation - Dumbbell", + "force": "static", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Dumbbell", + "primaryMuscles": [ + "rotator cuff" + ], + "primaryMuscle": "rotator cuff", + "secondaryMuscles": [], + "instructions": [ + "Lie on your back on a flat surface with a dumbbell nearby.", + "Raise one arm at the shoulder joint to your side so that your upper arm is perpendicular to your body. Bend your elbow at a 90-degree angle bringing your forearm perpendicular to the upper arm so that your hand is above your head on your supporting surface. This position is commonly known as as a single-arm \"cactus pose\".", + "Hold a dumbbell in your hand with your palm facing up towards the ceiling.", + "Slowly rotate your arm up towards the ceiling until it's perpendicular to the ground, keeping your elbow bent and your upper arm stationary.", + "Pause for a moment at the top of the movement, then slowly lower the dumbbell back to the starting position.", + "Repeat for the desired number of repetitions, then switch sides and repeat the exercise with your other arm." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_horizontal_wood_chop_band", + "name": "Horizontal Wood Chop - Band", + "force": "static", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Band", + "primaryMuscles": [ + "obliques" + ], + "primaryMuscle": "obliques", + "secondaryMuscles": [ + "abdominals" + ], + "instructions": [ + "Attach a resistance band to a sturdy anchor point at waist height.", + "Stand perpendicular to the anchor point with feet shoulder-width apart and knees slightly bent.", + "Hold the resistance band with both hands and extend arms out in front of the body.", + "Rotate the torso and pull the band across the body towards the opposite hip.", + "Keep the arms straight and engage the core muscles throughout the movement.", + "Slowly return to the starting position", + "Repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_horizontal_wood_chop_cable", + "name": "Horizontal Wood Chop - Cable", + "force": "static", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Cable", + "primaryMuscles": [ + "obliques" + ], + "primaryMuscle": "obliques", + "secondaryMuscles": [ + "abdominals" + ], + "instructions": [ + "Set up the cable machine by adjusting the cable to just under chest height and attach a handle.", + "Stand perpendicular to the cable machine with your feet shoulder-width apart, keeping your toes and head angled away from the machine.", + "Grip the handle with both hands, extending your arms fully. Begin with your upper body twisted so that your arms and the handle are positioned on the side closest to the machine, while your head and toes continue to face away.", + "Step away from the machine to create enough tension in the cable, ensuring it is taut before you start the exercise.", + "Rotate your torso while keeping your arms straight, moving the handle in a horizontal motion across your body until your hands are aligned forward, matching the direction of your toes and head.", + "Keep your core engaged throughout the movement to maintain stability and prevent any rotation in your hips or bending of the arms.", + "Complete the twisting motion until your arms are fully extended in front of you, parallel to the direction your toes and head are facing.", + "Slowly and with control, reverse the motion, returning to the twisted starting position while maintaining the alignment of your head and toes facing forward." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_incline_bench_press_smith_machine", + "name": "Incline Bench Press - Smith Machine", + "force": "push", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Machine", + "primaryMuscles": [ + "chest", + "front delts" + ], + "primaryMuscle": "chest", + "secondaryMuscles": [ + "triceps" + ], + "instructions": [ + "Adjust the height of the Smith machine bar to a comfortable level for the incline bench press.", + "Select the desired weight and load it onto the bar.", + "Lie down on the incline bench and position yourself under the bar.", + "Grip the bar with a slightly wider than shoulder-width grip and lift it off the rack.", + "Lower the bar down to your chest while keeping your elbows tucked in.", + "Push the bar back up to the starting position, keeping your core engaged and your back flat on the bench.", + "Repeat.", + "When finished, carefully rack the bar back onto the Smith machine." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_internal_shoulder_rotation_band", + "name": "Internal Shoulder Rotation - Band", + "force": "static", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Band", + "primaryMuscles": [ + "rotator cuff" + ], + "primaryMuscle": "rotator cuff", + "secondaryMuscles": [], + "instructions": [ + "Attach a resistance band to a stable object at waist height.", + "Stand perpendicular to the anchor point with your right arm closest to it, and your feet shoulder-width apart.", + "Reach your right hand and grab the band.", + "Create resistance in the band by side-stepping away from the anchor point.", + "This starting position has your right forearm at a 90-degree angle to your right upper-arm. Your right forearm points to your right.", + "Keep your core engaged and your shoulders relaxed.", + "Slowly rotate your arms inward, toward your body, until your right hand is in line with your right shoulder.", + "Pause for a moment, then slowly return to the starting position.", + "Repeat for the desired number of repetitions and then switch sides." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_internal_shoulder_rotation_cable", + "name": "Internal Shoulder Rotation - Cable", + "force": "static", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Cable", + "primaryMuscles": [ + "rotator cuff" + ], + "primaryMuscle": "rotator cuff", + "secondaryMuscles": [ + "forearms", + "front delts" + ], + "instructions": [ + "Stand side-on to a cable machine, with the machine arm set at the middle height. Attach a single handle.", + "Grab the handle with the hand that's closest to the machine, ensuring that your elbow is bent at a 90-degree angle.", + "Step away from the machine so that the weight is lifted slightly from its stack and there is tension on the cable.", + "Position your elbow so that it is close to your side and your forearm points towards the machine as far as naturally possible.", + "Slowly rotate your arm inward, pulling the handle away from the machine while keeping your elbow fixed at your side.", + "Rotate the arm until the hand crosses your abdomen without releasing tension on your front rotator cuff and without compromising form.", + "Pause for a moment at the maximum rotation point.", + "Slowly return the handle to the starting position in a controlled manner without allowing the weight to pull your arm back too fast.", + "Repeat the exercise for the desired number of reps before switching sides to ensure both front rotator cuffs are worked evenly." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": [ + "Keep your feet shoulder-width apart for stable footing and engage your core. Keep your body straight and avoid rotating your hips or shoulders during the movement. Your forearm should be perpendicular to your torso throughout the exercise." + ], + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_landmine_rotation", + "name": "Landmine Rotation", + "force": "static", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Barbell", + "primaryMuscles": [ + "abdominals", + "obliques" + ], + "primaryMuscle": "abdominals", + "secondaryMuscles": [ + "glutes", + "lower back", + "rotator cuff", + "front delts", + "side delts" + ], + "instructions": [ + "Place one end of a barbell securely into a landmine attachment or a corner to stabilize it.", + "Stand with your feet shoulder-width apart, facing the end of the barbell, and grab the end of the bar with both hands.", + "Press the barbell up and hold it in front of your head with your arms extended, while keeping your elbows slightly bent.", + "Begin to rotate your torso to one side; as you do, allow your hands holding the barbell to drop towards the hip on the same side as the rotation. During the rotation, keep your arms in the same position, extended with elbows bent, as much as possible.", + "Simultaneously, allow the foot you are rotating away from to rise up to the ball of the foot at the bottom of the rotation, facilitating a full rotation of the hips.", + "Reverse the direction and rotate your torso to the opposite side, repeating the arm movement and foot pivot to complete one full repetition.", + "Continue alternating the rotational movements for the desired number of repetitions, maintaining the appropriate arm and foot mechanics throughout." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_leg_press_machine", + "name": "Leg Press - Machine", + "force": "push", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Machine", + "primaryMuscles": [ + "glutes", + "quadriceps", + "adductors" + ], + "primaryMuscle": "glutes", + "secondaryMuscles": [ + "hamstrings" + ], + "instructions": [ + "Adjust the seat of the leg press machine so that your knees are at a ~90-degree angle when your feet are on the footplate.", + "Sit on the machine and place your feet shoulder-width apart on the footplate.", + "Push the footplate away from your body by extending your legs.", + "Lower the footplate back towards your body by bending your legs and keeping your feet flat on the plate.", + "Repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": [ + "Keep your back flat against the seat throughout the movement. Keep your feet flat on the footplate, and make sure your knees are tracking in line with your toes. Exhale as you push the footplate away from your body, and inhale as you lower it back down. Avoid locking out your knees at the top of the movement, as this can put unnecessary stress on the joint." + ], + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_lying_external_shoulder_rotation_dumbbell", + "name": "Lying External Shoulder Rotation - Dumbbell", + "force": "static", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Dumbbell", + "primaryMuscles": [ + "rotator cuff" + ], + "primaryMuscle": "rotator cuff", + "secondaryMuscles": [], + "instructions": [ + "Lie on your side on a flat bench with your legs stacked, and your body straight as possible from head to toes.", + "Bend your upper arm (the one farthest from the bench) at a 90-degree angle at your elbow with your forearm parallel to the floor, and grasp a dumbbell with a your palm facing the floor.", + "Rotate your shoulder while keeping your elbow at a 90-degree angle, moving your forearm towards the ceiling and above your body.", + "Rotate your arm upward as far as comfortably possible without lifting your elbow off your torso or your body moving off its position.", + "Pause at the top of the movement for a brief moment.", + "Slowly return the dumbbell to the starting position by rotating the forearm back to the horizontal starting position while keeping the elbow angle constant.", + "Complete all the desired repetitions on one arm before switching to the other arm and repeating the steps." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": [ + "Keep your elbow pinned to your torso throughout the exercise." + ], + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_lying_internal_shoulder_rotation_dumbbell", + "name": "Lying Internal Shoulder Rotation - Dumbbell", + "force": "static", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Dumbbell", + "primaryMuscles": [ + "rotator cuff" + ], + "primaryMuscle": "rotator cuff", + "secondaryMuscles": [], + "instructions": [ + "Lie on your side on a flat bench with your legs stacked, and your body straight as possible from head to toes.", + "Bend your lower arm (the one closest to the bench) at a 90-degree angle at your elbow, and grasp a dumbbell with a your palm facing the ceiling.", + "Rotate your shoulder while keeping your elbow at a 90-degree angle, moving your forearm towards the ceiling and inwards towards your body.", + "Rotate your arm upward as far as comfortably possible without lifting your elbow off the bench or your body moving off its position.", + "Pause at the top of the movement for a brief moment.", + "Slowly return the dumbbell to the starting position by rotating the forearm back to the bench while keeping the elbow angle constant.", + "Complete all the desired repetitions on one arm before switching to the other arm and repeating the steps." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": [ + "Keep your elbow tucked into your side and pinned to the bench throughout the exercise." + ], + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_one_handed_shoulder_press_landmine", + "name": "One-Handed Shoulder Press - Landmine", + "force": "push", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Barbell", + "primaryMuscles": [ + "chest", + "front delts", + "side delts", + "triceps" + ], + "primaryMuscle": "chest", + "secondaryMuscles": [ + "abdominals", + "obliques", + "rotator cuff", + "traps" + ], + "instructions": [ + "Position a barbell securely in a landmine attachment or securely in a corner to prevent it from sliding.", + "Load the desired weight onto the free end of the barbell.", + "Stand facing the barbell with your feet shoulder-width apart for stability.", + "Bend slightly to grab the barbell with one hand directly where the barbell is loaded.", + "Raise the barbell to your shoulder level, switching your grip so that your hand is under the barbell and your palm faces your chin over a bent and tucked elbow. This is your starting position.", + "Brace your core, and exhale as you press the barbell upward and slightly forward by extending your arm.", + "Keep your body straight and avoid rotating your hips or shoulders during the press.", + "Inhale as you slowly return the barbell to the starting position at your shoulder.", + "Complete all reps on one side before switching hands to work the opposite side." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_pause_squat_barbell", + "name": "Pause Squat - Barbell", + "force": "push", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Barbell", + "primaryMuscles": [ + "glutes", + "lower back", + "quadriceps", + "adductors" + ], + "primaryMuscle": "glutes", + "secondaryMuscles": [ + "calves" + ], + "instructions": [ + "Start with your feet shoulder-width apart, your toes pointing slightly outward, and the barbell on your shoulders.", + "Lower your body into a squat position, keeping your back straight and your knees tracking with your toes.", + "Pause at the bottom of the squat for 2-3 seconds.", + "Slowly rise back up to the starting position.", + "Repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_pull_apart_band", + "name": "Pull-Apart - Band", + "force": "pull", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Band", + "primaryMuscles": [ + "rotator cuff", + "rear delts" + ], + "primaryMuscle": "rotator cuff", + "secondaryMuscles": [ + "rotator cuff" + ], + "instructions": [ + "Stand with feet shoulder-width apart and hold a resistance band with both hands.", + "Extend your arms straight out in front of you, keeping your palms facing down.", + "Begin pulling the band apart by squeezing your shoulder blades together.", + "Continue pulling until your hands are at shoulder height and the band is stretched tight.", + "Hold for a moment, then slowly release the tension and return to the starting position.", + "Repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_pullover_barbell", + "name": "Pullover - Barbell", + "force": "pull", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Barbell", + "primaryMuscles": [ + "chest", + "lats", + "triceps" + ], + "primaryMuscle": "chest", + "secondaryMuscles": [ + "rotator cuff" + ], + "instructions": [ + "Lie on a bench with your head and shoulders supported, and your feet flat on the floor.", + "Hold a barbell with a pronated grip (palms facing forward) slightly wider than shoulder-width, and press it up to arm's length over your chest. This is your starting position.", + "Keep a slight bend in your elbows and lock your arms in this slightly bent position throughout the entire movement.", + "Inhale and slowly lower the barbell in an arc behind your head while keeping your arms straight, moving primarily at the shoulder joints.", + "Lower the barbell until your arms are parallel to the floor or until you feel a stretch in your chest or lats, whichever comes first.", + "Exhale as you reverse the motion, bringing the barbell back over your chest in the same arc motion.", + "Repeat for the desired number of reps." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_pullover_dumbbell", + "name": "Pullover - Dumbbell", + "force": "pull", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Dumbbell", + "primaryMuscles": [ + "chest", + "lats", + "triceps" + ], + "primaryMuscle": "chest", + "secondaryMuscles": [ + "rotator cuff" + ], + "instructions": [ + "Lie on a bench with your head and shoulders supported, and your feet flat on the floor.", + "Hold a dumbbell with both hands, palms pressing against the underside of one of the sides of the dumbbell. Push the dumbbell towards the sky above your chest. This is your starting position.", + "Keep a slight bend in your elbows and lock your arms in this slightly bent position throughout the entire movement.", + "Inhale and slowly lower the dumbbell in an arc behind your head while keeping your arms straight, moving primarily at the shoulder joints.", + "Lower the dumbbell until your arms are parallel to the floor or until you feel a stretch in your chest or lats, whichever comes first.", + "Exhale as you reverse the motion, bringing the dumbbell back over your chest in the same arc motion.", + "Repeat for the desired number of reps." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_rear_delt_row_barbell", + "name": "Rear Delt Row - Barbell", + "force": "pull", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Barbell", + "primaryMuscles": [ + "rear delts", + "traps" + ], + "primaryMuscle": "rear delts", + "secondaryMuscles": [ + "forearms", + "rotator cuff", + "side delts", + "lats" + ], + "instructions": [ + "Stand with your feet shoulder-width apart and your knees slightly bent.", + "Hold a barbell with an overhand grip, hands shoulder-width apart.", + "Lean forward at the hips, keeping your back straight and your abs engaged.", + "Bring the barbell up towards your upper chest, keeping your elbows wide and out to the sides.", + "Squeeze your shoulder blades together at the top of the movement.", + "Lower the barbell back down to the starting position.", + "Repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_rear_delt_row_cable", + "name": "Rear Delt Row - Cable", + "force": "pull", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Cable", + "primaryMuscles": [ + "rear delts", + "traps" + ], + "primaryMuscle": "rear delts", + "secondaryMuscles": [ + "forearms", + "rotator cuff", + "side delts", + "lats" + ], + "instructions": [ + "Attach a straight bar to a cable machine, preferably with a bench and foot rests.", + "Sit facing the cable machine with your feet shoulder-width apart and your knees slightly bent.", + "Grasp the bar with an overhand grip, with your hands slightly more than shoulder-width apart.", + "Lean forward slightly, keeping your back straight and your core engaged.", + "Pull the bar towards your upper chest, keeping your elbows wide and out to the sides.", + "Squeeze your shoulder blades together at the top of the movement.", + "Slowly release the bar back to the starting position.", + "Repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_rear_delt_row_dumbbell", + "name": "Rear Delt Row - Dumbbell", + "force": "pull", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Dumbbell", + "primaryMuscles": [ + "rear delts", + "traps" + ], + "primaryMuscle": "rear delts", + "secondaryMuscles": [ + "forearms", + "rotator cuff", + "side delts", + "lats" + ], + "instructions": [ + "Stand up straight holding a dumbbell in each hand with an overhand grip, palms facing your thighs.", + "Hinge forward at the hips with a slight bend in the knees, keeping your back straight and nearly parallel to the floor.", + "Let the dumbbells hang directly in front of you with your arms fully extended. This is the starting position.", + "Keep your eyes looking slightly ahead to maintain neck alignment with your spine.", + "Exhale as you pull the dumbbells towards your upper chest, keeping your elbows flared out. Imagine you are squeezing your shoulder blades together.", + "Hold the contraction for a second when the dumbbells reach the peak position when your upper arm is just beyond horizontal.", + "Inhale as you slowly lower the dumbbells back to the starting position in a controlled manner.", + "Repeat the movement for the desired number of repetitions." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_row_t_bar", + "name": "Row - T-Bar", + "force": "pull", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Barbell", + "primaryMuscles": [ + "lats", + "rear delts", + "traps" + ], + "primaryMuscle": "lats", + "secondaryMuscles": [ + "biceps", + "forearms", + "lower back", + "rotator cuff" + ], + "instructions": [ + "Load the desired weight onto the T-Bar machine.", + "Lie down on the T-Bar machine with your feet securely planted.", + "Grasp the handles of the T-Bar with an overhand grip.", + "Pull the T-Bar towards your chest, keeping your elbows close to your body.", + "Pause briefly at the top of the movement, squeezing your shoulder blades together.", + "Slowly lower the T-Bar back to the starting position.", + "Repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_seated_shoulder_press_smith_machine", + "name": "Seated Shoulder Press - Smith Machine", + "force": "push", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Machine", + "primaryMuscles": [ + "front delts" + ], + "primaryMuscle": "front delts", + "secondaryMuscles": [ + "side delts", + "triceps" + ], + "instructions": [ + "Sit on a back-supporting seat under a Smith Machine with your back straight and feet flat on the floor.", + "Adjust the height of the barbell on the Smith Machine so that it is at shoulder level.", + "Grasp the barbell with an overhand grip, slightly wider than shoulder-width apart.", + "Engage your core and press the barbell upward until your arms are fully extended, but not locked.", + "Pause for a moment at the top of the movement, then slowly lower the barbell back down to shoulder level.", + "Repeat for the desired number of repetitions." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_shrug_trap_bar", + "name": "Shrug - Trap Bar", + "force": "pull", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Barbell", + "primaryMuscles": [ + "traps" + ], + "primaryMuscle": "traps", + "secondaryMuscles": [ + "forearms" + ], + "instructions": [ + "Stand inside a trap bar with your feet shoulder-width apart.", + "Grasp the handles of the trap bar with an overhand grip.", + "Keep your back straight and your core engaged.", + "Exhale and lift your shoulders up towards your ears, squeezing your trapezius muscles.", + "Hold the shrug position for a second.", + "Inhale and slowly lower your shoulders back down to the starting position.", + "Repeat for the desired number of repetitions." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_single_arm_half_kneeling_high_row_cable", + "name": "Single Arm Half Kneeling High Row - Cable", + "force": "pull", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Cable", + "primaryMuscles": [ + "lats" + ], + "primaryMuscle": "lats", + "secondaryMuscles": [ + "obliques", + "rotator cuff", + "rear delts", + "traps" + ], + "instructions": [ + "Position a cable machine at the top setting and attach a single handle.", + "Assume a half-kneeling position with your right knee on the ground and left foot forward.", + "Grasp the handle with your right hand while keeping the arm extended.", + "Engage your core and keep your torso upright.", + "Pull the handle down and backwards by engaging your lat, keeping the elbow close to your body.", + "Pull until your hand reaches chest level, then pause briefly.", + "Slowly extend your arm back to the start position, controlling the movement.", + "Complete the desired number of repetitions and switch to the other side." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_single_leg_romanian_deadlift_landmine", + "name": "Single-Leg Romanian Deadlift - Landmine", + "force": "pull", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Barbell", + "primaryMuscles": [ + "glutes", + "hamstrings" + ], + "primaryMuscle": "glutes", + "secondaryMuscles": [ + "abdominals", + "calves", + "lower back", + "obliques", + "quadriceps" + ], + "instructions": [ + "Stand with your right side facing the end of a barbell secured in a landmine attachment, holding the bar with your right hand using a pronated (overhand) grip. The end of the barbell should be at arms-length in front of your quad. This is your starting position.", + "Shift your weight onto your left leg, keeping a slight bend in the knee of your standing leg.", + "Hinge at your hips and lean forward, extending your right leg straight back for balance.", + "Lower the barbell toward the floor while keeping your back straight and core engaged, ensuring your hips remain square.", + "Continue lowering until you feel a stretch in the hamstring of the standing leg or your upper body is nearly parallel to the floor.", + "Drive through the heel of the standing leg to return to the starting position, engaging your glutes as you stand.", + "Keep your gaze forward and chest lifted throughout the movement to maintain a flat back.", + "Repeat for the desired number of reps before switching to the opposite leg." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_squat_belt", + "name": "Squat - Belt", + "force": "push", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Other", + "primaryMuscles": [ + "glutes", + "quadriceps", + "adductors" + ], + "primaryMuscle": "glutes", + "secondaryMuscles": [ + "calves" + ], + "instructions": [ + "Add your desired weight to the machine.", + "Attach a belt or harness around your waist.", + "Attach a cable or chain to the belt or harness.", + "Stand on a platform or box with feet shoulder-width apart.", + "Hold onto a stable object for balance.", + "Squat by bending the knees and hips while keeping the back straight.", + "Pause at the bottom of the squat and then push up through the legs to return to the starting position.", + "Repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_squat_landmine", + "name": "Squat - Landmine", + "force": "push", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Barbell", + "primaryMuscles": [ + "glutes", + "quadriceps", + "adductors" + ], + "primaryMuscle": "glutes", + "secondaryMuscles": [ + "calves" + ], + "instructions": [ + "Start by placing a barbell into a landmine attachment or securely anchor it into a corner.", + "Stand facing the barbell with your feet shoulder-width apart and your toes pointing slightly outward.", + "Grab the end of the barbell with both hands and lift it up to your chest.", + "Lower your hips down and back, keeping your chest up and your back straight.", + "Bend your knees and lower your body until your thighs are parallel to the ground.", + "Push through your heels and stand back up to the starting position.", + "Repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_squat_smith_machine", + "name": "Squat - Smith Machine", + "force": "push", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Machine", + "primaryMuscles": [ + "glutes", + "lower back", + "quadriceps", + "adductors" + ], + "primaryMuscle": "glutes", + "secondaryMuscles": [ + "calves" + ], + "instructions": [ + "Adjust the bar height to your desired position.", + "Load the bar with an appropriate weight.", + "Stand facing the bar with your feet shoulder-width apart.", + "Step forward and position the bar on your shoulders, resting on the back of your neck.", + "Grasp the bar with both hands, keeping your elbows pointed down and your chest up.", + "Lower your body by bending at the knees and hips, keeping your back straight and your head up.", + "Lower until your thighs are parallel to the ground, then push back up to the starting position.", + "Repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_stability_ball_pullover_dumbbell", + "name": "Stability Ball Pullover - Dumbbell", + "force": "pull", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Dumbbell", + "primaryMuscles": [ + "chest", + "lats", + "triceps" + ], + "primaryMuscle": "chest", + "secondaryMuscles": [ + "abdominals", + "rotator cuff" + ], + "instructions": [ + "Select an appropriate weight dumbbell and a stability ball.", + "Sit on the stability ball and place the dumbbell on your lap.", + "Roll down the ball until your upper back and shoulders are supported by it, keeping your hips lifted, your body in a straight line, and your feet flat on the ground.", + "Hold the dumbbell over your chest with both hands, gripping one end of the dumbbell while the other end points towards your chest. This is your starting position.", + "Slowly lower the dumbbell in an arc behind your head, keeping your arms mostly straight, but slightly bent, throughout.", + "Stretch your arms back as far as comfortably possible while maintaining control of the weight.", + "Bring the dumbbell back up over your chest along the same arc.", + "Repeat for the desired number of repetitions." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_standing_external_shoulder_rotation_dumbbell", + "name": "Standing External Shoulder Rotation - Dumbbell", + "force": "static", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Dumbbell", + "primaryMuscles": [ + "rotator cuff" + ], + "primaryMuscle": "rotator cuff", + "secondaryMuscles": [ + "rotator cuff", + "rear delts", + "front delts", + "side delts" + ], + "instructions": [ + "Stand with feet shoulder-width apart, holding a dumbbell in one hand.", + "Bend the arm at the shoulder to raise your upper arm, making it parallel to the floor and perpendicular to your body.", + "Bend your forearm at the elbow to a 90-degree angle so that your forearm is parallel to the floor, creating an 'L' shape with your arm. This is the starting position.", + "Keeping your upper arm stationary, rotate your shoulder, lifting the dumbbell towards the ceiling as far as possible without discomfort.", + "Pause briefly at the top of the rotation, then slowly return the dumbbell to the starting position.", + "Complete the desired number of repetitions, then switch arms and repeat the exercise." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_standing_glute_kickback_machine", + "name": "Standing Glute Kickback - Machine", + "force": "push", + "level": "intermediate", + "mechanic": "isolation", + "equipment": "Machine", + "primaryMuscles": [ + "glutes" + ], + "primaryMuscle": "glutes", + "secondaryMuscles": [ + "hamstrings", + "adductors" + ], + "instructions": [ + "Adjust the machine to the appropriate height for your body.", + "Stand facing the machine with your feet shoulder-width apart and your hands holding onto the handles.", + "Engage your glutes and lift one leg straight back behind you, keeping it in line with your body.", + "Pause at the top of the movement and squeeze your glutes.", + "Lower your leg back down to the starting position and repeat on the other side.", + "Complete the desired number of repetitions on each leg." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + }, + { + "id": "longhaul_upright_row_barbell", + "name": "Upright Row - Barbell", + "force": "pull", + "level": "intermediate", + "mechanic": "compound", + "equipment": "Barbell", + "primaryMuscles": [ + "side delts" + ], + "primaryMuscle": "side delts", + "secondaryMuscles": [ + "biceps", + "front delts", + "traps" + ], + "instructions": [ + "Stand with your feet shoulder-width apart and your knees slightly bent.", + "Grasp the barbell with an overhand grip, hands narrower than shoulder-width apart.", + "Hold the barbell in front of your thighs with your arms extended.", + "Exhale and lift the barbell straight up towards your chin, keeping it close to your body.", + "Pause for a moment at the top of the movement, then inhale and slowly lower the barbell back down to the starting position.", + "Repeat." + ], + "category": "strength", + "images": [], + "isCustom": false, + "coachingCues": null, + "source": "longhaul-fitness/exercises" + } +] \ No newline at end of file diff --git a/app/src/main/assets/ironlog/exercise_youtube_by_normalized_name.json b/app/src/main/assets/ironlog/exercise_youtube_by_normalized_name.json new file mode 100644 index 0000000..8cebd37 --- /dev/null +++ b/app/src/main/assets/ironlog/exercise_youtube_by_normalized_name.json @@ -0,0 +1,5240 @@ +{ + "34situp": { + "youtubeLink": "https://www.youtube.com/results?search_query=3%2F4+Sit-Up+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=3%2F4+Sit-Up+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "3/4 Sit-Up Abdominals exercise tutorial", + "category": "Abdominals" + }, + "abcrunchmachine": { + "youtubeLink": "https://www.youtube.com/results?search_query=Ab+Crunch+Machine+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Ab+Crunch+Machine+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Ab Crunch Machine Abdominals exercise tutorial", + "category": "Abdominals" + }, + "abroller": { + "youtubeLink": "https://www.youtube.com/results?search_query=Ab+Roller+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Ab+Roller+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Ab Roller Abdominals exercise tutorial", + "category": "Abdominals" + }, + "advancedkettlebellwindmill": { + "youtubeLink": "https://www.youtube.com/results?search_query=Advanced+Kettlebell+Windmill+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Advanced+Kettlebell+Windmill+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Advanced Kettlebell Windmill Abdominals exercise tutorial", + "category": "Abdominals" + }, + "airbike": { + "youtubeLink": "https://www.youtube.com/results?search_query=Air+Bike+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Air+Bike+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Air Bike Abdominals exercise tutorial", + "category": "Abdominals" + }, + "alternateheeltouchers": { + "youtubeLink": "https://www.youtube.com/results?search_query=Alternate+Heel+Touchers+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Alternate+Heel+Touchers+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Alternate Heel Touchers Abdominals exercise tutorial", + "category": "Abdominals" + }, + "barbellabrollout": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Ab+Rollout+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Ab+Rollout+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Ab Rollout Abdominals exercise tutorial", + "category": "Abdominals" + }, + "barbellabrolloutonknees": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Ab+Rollout+-+On+Knees+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Ab+Rollout+-+On+Knees+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Ab Rollout - On Knees Abdominals exercise tutorial", + "category": "Abdominals" + }, + "barbellrolloutfrombench": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Rollout+from+Bench+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Rollout+from+Bench+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Rollout from Bench Abdominals exercise tutorial", + "category": "Abdominals" + }, + "barbellsidebend": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Side+Bend+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Side+Bend+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Side Bend Abdominals exercise tutorial", + "category": "Abdominals" + }, + "bentpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bent+Press+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bent+Press+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bent Press Abdominals exercise tutorial", + "category": "Abdominals" + }, + "bentkneehipraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bent-Knee+Hip+Raise+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bent-Knee+Hip+Raise+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bent-Knee Hip Raise Abdominals exercise tutorial", + "category": "Abdominals" + }, + "bosuballcablecrunchwithsidebends": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bosu+Ball+Cable+Crunch+With+Side+Bends+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bosu+Ball+Cable+Crunch+With+Side+Bends+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bosu Ball Cable Crunch With Side Bends Abdominals exercise tutorial", + "category": "Abdominals" + }, + "bottomsup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bottoms+Up+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bottoms+Up+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bottoms Up Abdominals exercise tutorial", + "category": "Abdominals" + }, + "buttups": { + "youtubeLink": "https://www.youtube.com/results?search_query=Butt-Ups+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Butt-Ups+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Butt-Ups Abdominals exercise tutorial", + "category": "Abdominals" + }, + "cablecrunch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Crunch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Crunch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Crunch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "cablejudoflip": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Judo+Flip+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Judo+Flip+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Judo Flip Abdominals exercise tutorial", + "category": "Abdominals" + }, + "cablereversecrunch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Reverse+Crunch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Reverse+Crunch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Reverse Crunch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "cablerussiantwists": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Russian+Twists+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Russian+Twists+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Russian Twists Abdominals exercise tutorial", + "category": "Abdominals" + }, + "cableseatedcrunch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Seated+Crunch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Seated+Crunch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Seated Crunch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "cocoons": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cocoons+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cocoons+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cocoons Abdominals exercise tutorial", + "category": "Abdominals" + }, + "crossbodycrunch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cross-Body+Crunch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cross-Body+Crunch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cross-Body Crunch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "crunchhandsoverhead": { + "youtubeLink": "https://www.youtube.com/results?search_query=Crunch+-+Hands+Overhead+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Crunch+-+Hands+Overhead+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Crunch - Hands Overhead Abdominals exercise tutorial", + "category": "Abdominals" + }, + "crunchlegsonexerciseball": { + "youtubeLink": "https://www.youtube.com/results?search_query=Crunch+-+Legs+On+Exercise+Ball+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Crunch+-+Legs+On+Exercise+Ball+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Crunch - Legs On Exercise Ball Abdominals exercise tutorial", + "category": "Abdominals" + }, + "crunches": { + "youtubeLink": "https://www.youtube.com/results?search_query=Crunches+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Crunches+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Crunches Abdominals exercise tutorial", + "category": "Abdominals" + }, + "deadbug": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dead+Bug+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dead+Bug+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dead Bug Abdominals exercise tutorial", + "category": "Abdominals" + }, + "declinecrunch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Decline+Crunch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Decline+Crunch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Decline Crunch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "declineobliquecrunch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Decline+Oblique+Crunch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Decline+Oblique+Crunch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Decline Oblique Crunch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "declinereversecrunch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Decline+Reverse+Crunch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Decline+Reverse+Crunch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Decline Reverse Crunch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "doublekettlebellwindmill": { + "youtubeLink": "https://www.youtube.com/results?search_query=Double+Kettlebell+Windmill+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Double+Kettlebell+Windmill+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Double Kettlebell Windmill Abdominals exercise tutorial", + "category": "Abdominals" + }, + "dumbbellsidebend": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Side+Bend+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Side+Bend+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Side Bend Abdominals exercise tutorial", + "category": "Abdominals" + }, + "elbowtoknee": { + "youtubeLink": "https://www.youtube.com/results?search_query=Elbow+to+Knee+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Elbow+to+Knee+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Elbow to Knee Abdominals exercise tutorial", + "category": "Abdominals" + }, + "exerciseballcrunch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Exercise+Ball+Crunch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Exercise+Ball+Crunch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Exercise Ball Crunch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "exerciseballpullin": { + "youtubeLink": "https://www.youtube.com/results?search_query=Exercise+Ball+Pull-In+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Exercise+Ball+Pull-In+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Exercise Ball Pull-In Abdominals exercise tutorial", + "category": "Abdominals" + }, + "flatbenchlegpullin": { + "youtubeLink": "https://www.youtube.com/results?search_query=Flat+Bench+Leg+Pull-In+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Flat+Bench+Leg+Pull-In+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Flat Bench Leg Pull-In Abdominals exercise tutorial", + "category": "Abdominals" + }, + "flatbenchlyinglegraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Flat+Bench+Lying+Leg+Raise+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Flat+Bench+Lying+Leg+Raise+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Flat Bench Lying Leg Raise Abdominals exercise tutorial", + "category": "Abdominals" + }, + "frogsitups": { + "youtubeLink": "https://www.youtube.com/results?search_query=Frog+Sit-Ups+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Frog+Sit-Ups+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Frog Sit-Ups Abdominals exercise tutorial", + "category": "Abdominals" + }, + "gorillachincrunch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Gorilla+Chin%2FCrunch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Gorilla+Chin%2FCrunch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Gorilla Chin/Crunch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "hanginglegraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hanging+Leg+Raise+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hanging+Leg+Raise+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hanging Leg Raise Abdominals exercise tutorial", + "category": "Abdominals" + }, + "hangingpike": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hanging+Pike+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hanging+Pike+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hanging Pike Abdominals exercise tutorial", + "category": "Abdominals" + }, + "jackknifesitup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Jackknife+Sit-Up+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Jackknife+Sit-Up+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Jackknife Sit-Up Abdominals exercise tutorial", + "category": "Abdominals" + }, + "jandasitup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Janda+Sit-Up+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Janda+Sit-Up+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Janda Sit-Up Abdominals exercise tutorial", + "category": "Abdominals" + }, + "kettlebellfigure8": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kettlebell+Figure+8+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kettlebell+Figure+8+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kettlebell Figure 8 Abdominals exercise tutorial", + "category": "Abdominals" + }, + "kettlebellpassbetweenthelegs": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kettlebell+Pass+Between+The+Legs+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kettlebell+Pass+Between+The+Legs+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kettlebell Pass Between The Legs Abdominals exercise tutorial", + "category": "Abdominals" + }, + "kettlebellwindmill": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kettlebell+Windmill+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kettlebell+Windmill+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kettlebell Windmill Abdominals exercise tutorial", + "category": "Abdominals" + }, + "kneehipraiseonparallelbars": { + "youtubeLink": "https://www.youtube.com/results?search_query=Knee%2FHip+Raise+On+Parallel+Bars+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Knee%2FHip+Raise+On+Parallel+Bars+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Knee/Hip Raise On Parallel Bars Abdominals exercise tutorial", + "category": "Abdominals" + }, + "kneelingcablecrunchwithalternatingobliquetwists": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kneeling+Cable+Crunch+With+Alternating+Oblique+Twists+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kneeling+Cable+Crunch+With+Alternating+Oblique+Twists+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kneeling Cable Crunch With Alternating Oblique Twists Abdominals exercise tutorial", + "category": "Abdominals" + }, + "landmine180s": { + "youtubeLink": "https://www.youtube.com/results?search_query=Landmine+180%27s+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Landmine+180%27s+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Landmine 180's Abdominals exercise tutorial", + "category": "Abdominals" + }, + "legpullin": { + "youtubeLink": "https://www.youtube.com/results?search_query=Leg+Pull-In+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Leg+Pull-In+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Leg Pull-In Abdominals exercise tutorial", + "category": "Abdominals" + }, + "lowerbackcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lower+Back+Curl+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lower+Back+Curl+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lower Back Curl Abdominals exercise tutorial", + "category": "Abdominals" + }, + "medicineballfulltwist": { + "youtubeLink": "https://www.youtube.com/results?search_query=Medicine+Ball+Full+Twist+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Medicine+Ball+Full+Twist+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Medicine Ball Full Twist Abdominals exercise tutorial", + "category": "Abdominals" + }, + "obliquecrunches": { + "youtubeLink": "https://www.youtube.com/results?search_query=Oblique+Crunches+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Oblique+Crunches+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Oblique Crunches Abdominals exercise tutorial", + "category": "Abdominals" + }, + "obliquecrunchesonthefloor": { + "youtubeLink": "https://www.youtube.com/results?search_query=Oblique+Crunches+-+On+The+Floor+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Oblique+Crunches+-+On+The+Floor+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Oblique Crunches - On The Floor Abdominals exercise tutorial", + "category": "Abdominals" + }, + "onearmhighpulleycablesidebends": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+High-Pulley+Cable+Side+Bends+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+High-Pulley+Cable+Side+Bends+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm High-Pulley Cable Side Bends Abdominals exercise tutorial", + "category": "Abdominals" + }, + "onearmmedicineballslam": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Medicine+Ball+Slam+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Medicine+Ball+Slam+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Medicine Ball Slam Abdominals exercise tutorial", + "category": "Abdominals" + }, + "otisup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Otis-Up+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Otis-Up+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Otis-Up Abdominals exercise tutorial", + "category": "Abdominals" + }, + "overheadstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Overhead+Stretch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Overhead+Stretch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Overhead Stretch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "pallofpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Pallof+Press+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Pallof+Press+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Pallof Press Abdominals exercise tutorial", + "category": "Abdominals" + }, + "pallofpresswithrotation": { + "youtubeLink": "https://www.youtube.com/results?search_query=Pallof+Press+With+Rotation+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Pallof+Press+With+Rotation+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Pallof Press With Rotation Abdominals exercise tutorial", + "category": "Abdominals" + }, + "plank": { + "youtubeLink": "https://www.youtube.com/results?search_query=Plank+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Plank+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Plank Abdominals exercise tutorial", + "category": "Abdominals" + }, + "platetwist": { + "youtubeLink": "https://www.youtube.com/results?search_query=Plate+Twist+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Plate+Twist+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Plate Twist Abdominals exercise tutorial", + "category": "Abdominals" + }, + "presssitup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Press+Sit-Up+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Press+Sit-Up+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Press Sit-Up Abdominals exercise tutorial", + "category": "Abdominals" + }, + "reversecrunch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Crunch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Crunch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Crunch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "ropecrunch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Rope+Crunch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Rope+Crunch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Rope Crunch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "russiantwist": { + "youtubeLink": "https://www.youtube.com/results?search_query=Russian+Twist+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Russian+Twist+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Russian Twist Abdominals exercise tutorial", + "category": "Abdominals" + }, + "scissorkick": { + "youtubeLink": "https://www.youtube.com/results?search_query=Scissor+Kick+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Scissor+Kick+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Scissor Kick Abdominals exercise tutorial", + "category": "Abdominals" + }, + "seatedbarbelltwist": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Barbell+Twist+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Barbell+Twist+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Barbell Twist Abdominals exercise tutorial", + "category": "Abdominals" + }, + "seatedflatbenchlegpullin": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Flat+Bench+Leg+Pull-In+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Flat+Bench+Leg+Pull-In+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Flat Bench Leg Pull-In Abdominals exercise tutorial", + "category": "Abdominals" + }, + "seatedlegtucks": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Leg+Tucks+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Leg+Tucks+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Leg Tucks Abdominals exercise tutorial", + "category": "Abdominals" + }, + "seatedoverheadstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Overhead+Stretch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Overhead+Stretch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Overhead Stretch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "sidebridge": { + "youtubeLink": "https://www.youtube.com/results?search_query=Side+Bridge+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Side+Bridge+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Side Bridge Abdominals exercise tutorial", + "category": "Abdominals" + }, + "sidejackknife": { + "youtubeLink": "https://www.youtube.com/results?search_query=Side+Jackknife+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Side+Jackknife+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Side Jackknife Abdominals exercise tutorial", + "category": "Abdominals" + }, + "situp": { + "youtubeLink": "https://www.youtube.com/results?search_query=Sit-Up+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Sit-Up+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Sit-Up Abdominals exercise tutorial", + "category": "Abdominals" + }, + "sledgehammerswings": { + "youtubeLink": "https://www.youtube.com/results?search_query=Sledgehammer+Swings+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Sledgehammer+Swings+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Sledgehammer Swings Abdominals exercise tutorial", + "category": "Abdominals" + }, + "smithmachinehipraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Hip+Raise+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Hip+Raise+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Hip Raise Abdominals exercise tutorial", + "category": "Abdominals" + }, + "spellcaster": { + "youtubeLink": "https://www.youtube.com/results?search_query=Spell+Caster+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Spell+Caster+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Spell Caster Abdominals exercise tutorial", + "category": "Abdominals" + }, + "spidercrawl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Spider+Crawl+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Spider+Crawl+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Spider Crawl Abdominals exercise tutorial", + "category": "Abdominals" + }, + "standingcablelift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Cable+Lift+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Cable+Lift+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Cable Lift Abdominals exercise tutorial", + "category": "Abdominals" + }, + "standingcablewoodchop": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Cable+Wood+Chop+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Cable+Wood+Chop+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Cable Wood Chop Abdominals exercise tutorial", + "category": "Abdominals" + }, + "standinglateralstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Lateral+Stretch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Lateral+Stretch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Lateral Stretch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "standingropecrunch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Rope+Crunch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Rope+Crunch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Rope Crunch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "stomachvacuum": { + "youtubeLink": "https://www.youtube.com/results?search_query=Stomach+Vacuum+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Stomach+Vacuum+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Stomach Vacuum Abdominals exercise tutorial", + "category": "Abdominals" + }, + "supineonearmoverheadthrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Supine+One-Arm+Overhead+Throw+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Supine+One-Arm+Overhead+Throw+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Supine One-Arm Overhead Throw Abdominals exercise tutorial", + "category": "Abdominals" + }, + "supinetwoarmoverheadthrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Supine+Two-Arm+Overhead+Throw+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Supine+Two-Arm+Overhead+Throw+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Supine Two-Arm Overhead Throw Abdominals exercise tutorial", + "category": "Abdominals" + }, + "suspendedfallout": { + "youtubeLink": "https://www.youtube.com/results?search_query=Suspended+Fallout+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Suspended+Fallout+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Suspended Fallout Abdominals exercise tutorial", + "category": "Abdominals" + }, + "suspendedreversecrunch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Suspended+Reverse+Crunch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Suspended+Reverse+Crunch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Suspended Reverse Crunch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "toetouchers": { + "youtubeLink": "https://www.youtube.com/results?search_query=Toe+Touchers+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Toe+Touchers+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Toe Touchers Abdominals exercise tutorial", + "category": "Abdominals" + }, + "torsorotation": { + "youtubeLink": "https://www.youtube.com/results?search_query=Torso+Rotation+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Torso+Rotation+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Torso Rotation Abdominals exercise tutorial", + "category": "Abdominals" + }, + "tuckcrunch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Tuck+Crunch+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Tuck+Crunch+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Tuck Crunch Abdominals exercise tutorial", + "category": "Abdominals" + }, + "weightedballsidebend": { + "youtubeLink": "https://www.youtube.com/results?search_query=Weighted+Ball+Side+Bend+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Weighted+Ball+Side+Bend+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Weighted Ball Side Bend Abdominals exercise tutorial", + "category": "Abdominals" + }, + "weightedcrunches": { + "youtubeLink": "https://www.youtube.com/results?search_query=Weighted+Crunches+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Weighted+Crunches+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Weighted Crunches Abdominals exercise tutorial", + "category": "Abdominals" + }, + "weightedsitupswithbands": { + "youtubeLink": "https://www.youtube.com/results?search_query=Weighted+Sit-Ups+-+With+Bands+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Weighted+Sit-Ups+-+With+Bands+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Weighted Sit-Ups - With Bands Abdominals exercise tutorial", + "category": "Abdominals" + }, + "windsprints": { + "youtubeLink": "https://www.youtube.com/results?search_query=Wind+Sprints+Abdominals+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Wind+Sprints+Abdominals+exercise+tutorial+shorts", + "youtubeSearchQuery": "Wind Sprints Abdominals exercise tutorial", + "category": "Abdominals" + }, + "hipcirclesprone": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hip+Circles+%28prone%29+Abductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hip+Circles+%28prone%29+Abductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hip Circles (prone) Abductors exercise tutorial", + "category": "Abductors" + }, + "itbandandglutestretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=IT+Band+and+Glute+Stretch+Abductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=IT+Band+and+Glute+Stretch+Abductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "IT Band and Glute Stretch Abductors exercise tutorial", + "category": "Abductors" + }, + "iliotibialtractsmr": { + "youtubeLink": "https://www.youtube.com/results?search_query=Iliotibial+Tract-SMR+Abductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Iliotibial+Tract-SMR+Abductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Iliotibial Tract-SMR Abductors exercise tutorial", + "category": "Abductors" + }, + "lyingcrossover": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Crossover+Abductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Crossover+Abductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Crossover Abductors exercise tutorial", + "category": "Abductors" + }, + "monsterwalk": { + "youtubeLink": "https://www.youtube.com/results?search_query=Monster+Walk+Abductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Monster+Walk+Abductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Monster Walk Abductors exercise tutorial", + "category": "Abductors" + }, + "standinghipcircles": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Hip+Circles+Abductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Hip+Circles+Abductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Hip Circles Abductors exercise tutorial", + "category": "Abductors" + }, + "thighabductor": { + "youtubeLink": "https://www.youtube.com/results?search_query=Thigh+Abductor+Abductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Thigh+Abductor+Abductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Thigh Abductor Abductors exercise tutorial", + "category": "Abductors" + }, + "windmills": { + "youtubeLink": "https://www.youtube.com/results?search_query=Windmills+Abductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Windmills+Abductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Windmills Abductors exercise tutorial", + "category": "Abductors" + }, + "adductor": { + "youtubeLink": "https://www.youtube.com/results?search_query=Adductor+Adductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Adductor+Adductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Adductor Adductors exercise tutorial", + "category": "Adductors" + }, + "adductorgroin": { + "youtubeLink": "https://www.youtube.com/results?search_query=Adductor%2FGroin+Adductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Adductor%2FGroin+Adductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Adductor/Groin Adductors exercise tutorial", + "category": "Adductors" + }, + "bandhipadductions": { + "youtubeLink": "https://www.youtube.com/results?search_query=Band+Hip+Adductions+Adductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Band+Hip+Adductions+Adductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Band Hip Adductions Adductors exercise tutorial", + "category": "Adductors" + }, + "cariocaquickstep": { + "youtubeLink": "https://www.youtube.com/results?search_query=Carioca+Quick+Step+Adductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Carioca+Quick+Step+Adductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Carioca Quick Step Adductors exercise tutorial", + "category": "Adductors" + }, + "groinandbackstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Groin+and+Back+Stretch+Adductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Groin+and+Back+Stretch+Adductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Groin and Back Stretch Adductors exercise tutorial", + "category": "Adductors" + }, + "groiners": { + "youtubeLink": "https://www.youtube.com/results?search_query=Groiners+Adductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Groiners+Adductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Groiners Adductors exercise tutorial", + "category": "Adductors" + }, + "lateralbound": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lateral+Bound+Adductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lateral+Bound+Adductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lateral Bound Adductors exercise tutorial", + "category": "Adductors" + }, + "lateralboxjump": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lateral+Box+Jump+Adductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lateral+Box+Jump+Adductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lateral Box Jump Adductors exercise tutorial", + "category": "Adductors" + }, + "lateralconehops": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lateral+Cone+Hops+Adductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lateral+Cone+Hops+Adductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lateral Cone Hops Adductors exercise tutorial", + "category": "Adductors" + }, + "lyingbentleggroin": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Bent+Leg+Groin+Adductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Bent+Leg+Groin+Adductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Bent Leg Groin Adductors exercise tutorial", + "category": "Adductors" + }, + "sidelegraises": { + "youtubeLink": "https://www.youtube.com/results?search_query=Side+Leg+Raises+Adductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Side+Leg+Raises+Adductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Side Leg Raises Adductors exercise tutorial", + "category": "Adductors" + }, + "sidelyinggroinstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Side+Lying+Groin+Stretch+Adductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Side+Lying+Groin+Stretch+Adductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Side Lying Groin Stretch Adductors exercise tutorial", + "category": "Adductors" + }, + "thighadductor": { + "youtubeLink": "https://www.youtube.com/results?search_query=Thigh+Adductor+Adductors+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Thigh+Adductor+Adductors+exercise+tutorial+shorts", + "youtubeSearchQuery": "Thigh Adductor Adductors exercise tutorial", + "category": "Adductors" + }, + "alternatehammercurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Alternate+Hammer+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Alternate+Hammer+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Alternate Hammer Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "alternateinclinedumbbellcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Alternate+Incline+Dumbbell+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Alternate+Incline+Dumbbell+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Alternate Incline Dumbbell Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "barbellcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "barbellcurlslyingagainstanincline": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Curls+Lying+Against+An+Incline+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Curls+Lying+Against+An+Incline+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Curls Lying Against An Incline Biceps exercise tutorial", + "category": "Biceps" + }, + "brachialissmr": { + "youtubeLink": "https://www.youtube.com/results?search_query=Brachialis-SMR+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Brachialis-SMR+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Brachialis-SMR Biceps exercise tutorial", + "category": "Biceps" + }, + "cablehammercurlsropeattachment": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Hammer+Curls+-+Rope+Attachment+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Hammer+Curls+-+Rope+Attachment+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Hammer Curls - Rope Attachment Biceps exercise tutorial", + "category": "Biceps" + }, + "cablepreachercurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Preacher+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Preacher+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Preacher Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "closegripezbarcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Close-Grip+EZ+Bar+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Close-Grip+EZ+Bar+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Close-Grip EZ Bar Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "closegripezbarcurlwithband": { + "youtubeLink": "https://www.youtube.com/results?search_query=Close-Grip+EZ-Bar+Curl+with+Band+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Close-Grip+EZ-Bar+Curl+with+Band+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Close-Grip EZ-Bar Curl with Band Biceps exercise tutorial", + "category": "Biceps" + }, + "closegripstandingbarbellcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Close-Grip+Standing+Barbell+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Close-Grip+Standing+Barbell+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Close-Grip Standing Barbell Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "concentrationcurls": { + "youtubeLink": "https://www.youtube.com/results?search_query=Concentration+Curls+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Concentration+Curls+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Concentration Curls Biceps exercise tutorial", + "category": "Biceps" + }, + "crossbodyhammercurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cross+Body+Hammer+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cross+Body+Hammer+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cross Body Hammer Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "dragcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Drag+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Drag+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Drag Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "dumbbellalternatebicepcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Alternate+Bicep+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Alternate+Bicep+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Alternate Bicep Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "dumbbellbicepcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Bicep+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Bicep+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Bicep Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "dumbbellproneinclinecurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Prone+Incline+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Prone+Incline+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Prone Incline Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "ezbarcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=EZ-Bar+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=EZ-Bar+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "EZ-Bar Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "flexorinclinedumbbellcurls": { + "youtubeLink": "https://www.youtube.com/results?search_query=Flexor+Incline+Dumbbell+Curls+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Flexor+Incline+Dumbbell+Curls+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Flexor Incline Dumbbell Curls Biceps exercise tutorial", + "category": "Biceps" + }, + "hammercurls": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hammer+Curls+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hammer+Curls+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hammer Curls Biceps exercise tutorial", + "category": "Biceps" + }, + "highcablecurls": { + "youtubeLink": "https://www.youtube.com/results?search_query=High+Cable+Curls+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=High+Cable+Curls+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "High Cable Curls Biceps exercise tutorial", + "category": "Biceps" + }, + "inclinedumbbellcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Dumbbell+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Dumbbell+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Dumbbell Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "inclinehammercurls": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Hammer+Curls+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Hammer+Curls+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Hammer Curls Biceps exercise tutorial", + "category": "Biceps" + }, + "inclineinnerbicepscurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Inner+Biceps+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Inner+Biceps+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Inner Biceps Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "lyingcablecurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Cable+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Cable+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Cable Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "lyingclosegripbarcurlonhighpulley": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Close-Grip+Bar+Curl+On+High+Pulley+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Close-Grip+Bar+Curl+On+High+Pulley+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Close-Grip Bar Curl On High Pulley Biceps exercise tutorial", + "category": "Biceps" + }, + "lyinghighbenchbarbellcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+High+Bench+Barbell+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+High+Bench+Barbell+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying High Bench Barbell Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "lyingsupinedumbbellcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Supine+Dumbbell+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Supine+Dumbbell+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Supine Dumbbell Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "machinebicepcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Machine+Bicep+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Machine+Bicep+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Machine Bicep Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "machinepreachercurls": { + "youtubeLink": "https://www.youtube.com/results?search_query=Machine+Preacher+Curls+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Machine+Preacher+Curls+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Machine Preacher Curls Biceps exercise tutorial", + "category": "Biceps" + }, + "onearmdumbbellpreachercurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=One+Arm+Dumbbell+Preacher+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One+Arm+Dumbbell+Preacher+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "One Arm Dumbbell Preacher Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "overheadcablecurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Overhead+Cable+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Overhead+Cable+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Overhead Cable Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "preachercurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Preacher+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Preacher+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Preacher Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "preacherhammerdumbbellcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Preacher+Hammer+Dumbbell+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Preacher+Hammer+Dumbbell+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Preacher Hammer Dumbbell Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "reversebarbellcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Barbell+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Barbell+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Barbell Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "reversebarbellpreachercurls": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Barbell+Preacher+Curls+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Barbell+Preacher+Curls+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Barbell Preacher Curls Biceps exercise tutorial", + "category": "Biceps" + }, + "reversecablecurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Cable+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Cable+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Cable Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "reverseplatecurls": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Plate+Curls+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Plate+Curls+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Plate Curls Biceps exercise tutorial", + "category": "Biceps" + }, + "seatedbiceps": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Biceps+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Biceps+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Biceps Biceps exercise tutorial", + "category": "Biceps" + }, + "seatedclosegripconcentrationbarbellcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Close-Grip+Concentration+Barbell+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Close-Grip+Concentration+Barbell+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Close-Grip Concentration Barbell Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "seateddumbbellcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Dumbbell+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Dumbbell+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Dumbbell Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "seateddumbbellinnerbicepscurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Dumbbell+Inner+Biceps+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Dumbbell+Inner+Biceps+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Dumbbell Inner Biceps Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "spidercurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Spider+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Spider+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Spider Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "standingbicepscablecurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Biceps+Cable+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Biceps+Cable+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Biceps Cable Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "standingbicepsstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Biceps+Stretch+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Biceps+Stretch+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Biceps Stretch Biceps exercise tutorial", + "category": "Biceps" + }, + "standingconcentrationcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Concentration+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Concentration+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Concentration Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "standingdumbbellreversecurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Dumbbell+Reverse+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Dumbbell+Reverse+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Dumbbell Reverse Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "standinginnerbicepscurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Inner-Biceps+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Inner-Biceps+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Inner-Biceps Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "standingonearmcablecurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+One-Arm+Cable+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+One-Arm+Cable+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing One-Arm Cable Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "standingonearmdumbbellcurloverinclinebench": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+One-Arm+Dumbbell+Curl+Over+Incline+Bench+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+One-Arm+Dumbbell+Curl+Over+Incline+Bench+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing One-Arm Dumbbell Curl Over Incline Bench Biceps exercise tutorial", + "category": "Biceps" + }, + "twoarmdumbbellpreachercurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Two-Arm+Dumbbell+Preacher+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Two-Arm+Dumbbell+Preacher+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Two-Arm Dumbbell Preacher Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "widegripstandingbarbellcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Wide-Grip+Standing+Barbell+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Wide-Grip+Standing+Barbell+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Wide-Grip Standing Barbell Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "zottmancurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Zottman+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Zottman+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Zottman Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "zottmanpreachercurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Zottman+Preacher+Curl+Biceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Zottman+Preacher+Curl+Biceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Zottman Preacher Curl Biceps exercise tutorial", + "category": "Biceps" + }, + "anklecircles": { + "youtubeLink": "https://www.youtube.com/results?search_query=Ankle+Circles+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Ankle+Circles+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Ankle Circles Calves exercise tutorial", + "category": "Calves" + }, + "anteriortibialissmr": { + "youtubeLink": "https://www.youtube.com/results?search_query=Anterior+Tibialis-SMR+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Anterior+Tibialis-SMR+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Anterior Tibialis-SMR Calves exercise tutorial", + "category": "Calves" + }, + "balanceboard": { + "youtubeLink": "https://www.youtube.com/results?search_query=Balance+Board+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Balance+Board+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Balance Board Calves exercise tutorial", + "category": "Calves" + }, + "barbellseatedcalfraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Seated+Calf+Raise+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Seated+Calf+Raise+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Seated Calf Raise Calves exercise tutorial", + "category": "Calves" + }, + "calfpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Calf+Press+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Calf+Press+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Calf Press Calves exercise tutorial", + "category": "Calves" + }, + "calfpressonthelegpressmachine": { + "youtubeLink": "https://www.youtube.com/results?search_query=Calf+Press+On+The+Leg+Press+Machine+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Calf+Press+On+The+Leg+Press+Machine+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Calf Press On The Leg Press Machine Calves exercise tutorial", + "category": "Calves" + }, + "calfraiseonadumbbell": { + "youtubeLink": "https://www.youtube.com/results?search_query=Calf+Raise+On+A+Dumbbell+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Calf+Raise+On+A+Dumbbell+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Calf Raise On A Dumbbell Calves exercise tutorial", + "category": "Calves" + }, + "calfraiseswithbands": { + "youtubeLink": "https://www.youtube.com/results?search_query=Calf+Raises+-+With+Bands+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Calf+Raises+-+With+Bands+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Calf Raises - With Bands Calves exercise tutorial", + "category": "Calves" + }, + "calfstretchelbowsagainstwall": { + "youtubeLink": "https://www.youtube.com/results?search_query=Calf+Stretch+Elbows+Against+Wall+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Calf+Stretch+Elbows+Against+Wall+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Calf Stretch Elbows Against Wall Calves exercise tutorial", + "category": "Calves" + }, + "calfstretchhandsagainstwall": { + "youtubeLink": "https://www.youtube.com/results?search_query=Calf+Stretch+Hands+Against+Wall+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Calf+Stretch+Hands+Against+Wall+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Calf Stretch Hands Against Wall Calves exercise tutorial", + "category": "Calves" + }, + "calvessmr": { + "youtubeLink": "https://www.youtube.com/results?search_query=Calves-SMR+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Calves-SMR+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Calves-SMR Calves exercise tutorial", + "category": "Calves" + }, + "donkeycalfraises": { + "youtubeLink": "https://www.youtube.com/results?search_query=Donkey+Calf+Raises+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Donkey+Calf+Raises+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Donkey Calf Raises Calves exercise tutorial", + "category": "Calves" + }, + "dumbbellseatedonelegcalfraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Seated+One-Leg+Calf+Raise+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Seated+One-Leg+Calf+Raise+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Seated One-Leg Calf Raise Calves exercise tutorial", + "category": "Calves" + }, + "footsmr": { + "youtubeLink": "https://www.youtube.com/results?search_query=Foot-SMR+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Foot-SMR+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Foot-SMR Calves exercise tutorial", + "category": "Calves" + }, + "kneecircles": { + "youtubeLink": "https://www.youtube.com/results?search_query=Knee+Circles+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Knee+Circles+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Knee Circles Calves exercise tutorial", + "category": "Calves" + }, + "peronealsstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Peroneals+Stretch+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Peroneals+Stretch+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Peroneals Stretch Calves exercise tutorial", + "category": "Calves" + }, + "peronealssmr": { + "youtubeLink": "https://www.youtube.com/results?search_query=Peroneals-SMR+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Peroneals-SMR+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Peroneals-SMR Calves exercise tutorial", + "category": "Calves" + }, + "posteriortibialisstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Posterior+Tibialis+Stretch+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Posterior+Tibialis+Stretch+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Posterior Tibialis Stretch Calves exercise tutorial", + "category": "Calves" + }, + "rockingstandingcalfraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Rocking+Standing+Calf+Raise+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Rocking+Standing+Calf+Raise+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Rocking Standing Calf Raise Calves exercise tutorial", + "category": "Calves" + }, + "seatedcalfraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Calf+Raise+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Calf+Raise+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Calf Raise Calves exercise tutorial", + "category": "Calves" + }, + "seatedcalfstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Calf+Stretch+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Calf+Stretch+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Calf Stretch Calves exercise tutorial", + "category": "Calves" + }, + "smithmachinecalfraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Calf+Raise+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Calf+Raise+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Calf Raise Calves exercise tutorial", + "category": "Calves" + }, + "smithmachinereversecalfraises": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Reverse+Calf+Raises+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Reverse+Calf+Raises+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Reverse Calf Raises Calves exercise tutorial", + "category": "Calves" + }, + "standingbarbellcalfraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Barbell+Calf+Raise+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Barbell+Calf+Raise+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Barbell Calf Raise Calves exercise tutorial", + "category": "Calves" + }, + "standingcalfraises": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Calf+Raises+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Calf+Raises+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Calf Raises Calves exercise tutorial", + "category": "Calves" + }, + "standingdumbbellcalfraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Dumbbell+Calf+Raise+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Dumbbell+Calf+Raise+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Dumbbell Calf Raise Calves exercise tutorial", + "category": "Calves" + }, + "standinggastrocnemiuscalfstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Gastrocnemius+Calf+Stretch+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Gastrocnemius+Calf+Stretch+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Gastrocnemius Calf Stretch Calves exercise tutorial", + "category": "Calves" + }, + "standingsoleusandachillesstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Soleus+And+Achilles+Stretch+Calves+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Soleus+And+Achilles+Stretch+Calves+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Soleus And Achilles Stretch Calves exercise tutorial", + "category": "Calves" + }, + "alternatingfloorpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Alternating+Floor+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Alternating+Floor+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Alternating Floor Press Chest exercise tutorial", + "category": "Chest" + }, + "aroundtheworlds": { + "youtubeLink": "https://www.youtube.com/results?search_query=Around+The+Worlds+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Around+The+Worlds+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Around The Worlds Chest exercise tutorial", + "category": "Chest" + }, + "barbellbenchpressmediumgrip": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Bench+Press+-+Medium+Grip+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Bench+Press+-+Medium+Grip+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Bench Press - Medium Grip Chest exercise tutorial", + "category": "Chest" + }, + "barbellguillotinebenchpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Guillotine+Bench+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Guillotine+Bench+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Guillotine Bench Press Chest exercise tutorial", + "category": "Chest" + }, + "barbellinclinebenchpressmediumgrip": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Incline+Bench+Press+-+Medium+Grip+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Incline+Bench+Press+-+Medium+Grip+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Incline Bench Press - Medium Grip Chest exercise tutorial", + "category": "Chest" + }, + "behindheadcheststretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Behind+Head+Chest+Stretch+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Behind+Head+Chest+Stretch+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Behind Head Chest Stretch Chest exercise tutorial", + "category": "Chest" + }, + "benchpresswithbands": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bench+Press+-+With+Bands+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bench+Press+-+With+Bands+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bench Press - With Bands Chest exercise tutorial", + "category": "Chest" + }, + "bentarmdumbbellpullover": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bent-Arm+Dumbbell+Pullover+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bent-Arm+Dumbbell+Pullover+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bent-Arm Dumbbell Pullover Chest exercise tutorial", + "category": "Chest" + }, + "bodyweightflyes": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bodyweight+Flyes+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bodyweight+Flyes+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bodyweight Flyes Chest exercise tutorial", + "category": "Chest" + }, + "butterfly": { + "youtubeLink": "https://www.youtube.com/results?search_query=Butterfly+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Butterfly+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Butterfly Chest exercise tutorial", + "category": "Chest" + }, + "cablechestpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Chest+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Chest+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Chest Press Chest exercise tutorial", + "category": "Chest" + }, + "cablecrossover": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Crossover+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Crossover+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Crossover Chest exercise tutorial", + "category": "Chest" + }, + "cableironcross": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Iron+Cross+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Iron+Cross+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Iron Cross Chest exercise tutorial", + "category": "Chest" + }, + "chainpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Chain+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Chain+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Chain Press Chest exercise tutorial", + "category": "Chest" + }, + "chestandfrontofshoulderstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Chest+And+Front+Of+Shoulder+Stretch+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Chest+And+Front+Of+Shoulder+Stretch+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Chest And Front Of Shoulder Stretch Chest exercise tutorial", + "category": "Chest" + }, + "chestpushmultipleresponse": { + "youtubeLink": "https://www.youtube.com/results?search_query=Chest+Push+%28multiple+response%29+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Chest+Push+%28multiple+response%29+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Chest Push (multiple response) Chest exercise tutorial", + "category": "Chest" + }, + "chestpushsingleresponse": { + "youtubeLink": "https://www.youtube.com/results?search_query=Chest+Push+%28single+response%29+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Chest+Push+%28single+response%29+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Chest Push (single response) Chest exercise tutorial", + "category": "Chest" + }, + "chestpushfrom3pointstance": { + "youtubeLink": "https://www.youtube.com/results?search_query=Chest+Push+from+3+point+stance+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Chest+Push+from+3+point+stance+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Chest Push from 3 point stance Chest exercise tutorial", + "category": "Chest" + }, + "chestpushwithrunrelease": { + "youtubeLink": "https://www.youtube.com/results?search_query=Chest+Push+with+Run+Release+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Chest+Push+with+Run+Release+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Chest Push with Run Release Chest exercise tutorial", + "category": "Chest" + }, + "cheststretchonstabilityball": { + "youtubeLink": "https://www.youtube.com/results?search_query=Chest+Stretch+on+Stability+Ball+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Chest+Stretch+on+Stability+Ball+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Chest Stretch on Stability Ball Chest exercise tutorial", + "category": "Chest" + }, + "clockpushup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Clock+Push-Up+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Clock+Push-Up+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Clock Push-Up Chest exercise tutorial", + "category": "Chest" + }, + "crossoverwithbands": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cross+Over+-+With+Bands+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cross+Over+-+With+Bands+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cross Over - With Bands Chest exercise tutorial", + "category": "Chest" + }, + "declinebarbellbenchpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Decline+Barbell+Bench+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Decline+Barbell+Bench+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Decline Barbell Bench Press Chest exercise tutorial", + "category": "Chest" + }, + "declinedumbbellbenchpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Decline+Dumbbell+Bench+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Decline+Dumbbell+Bench+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Decline Dumbbell Bench Press Chest exercise tutorial", + "category": "Chest" + }, + "declinedumbbellflyes": { + "youtubeLink": "https://www.youtube.com/results?search_query=Decline+Dumbbell+Flyes+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Decline+Dumbbell+Flyes+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Decline Dumbbell Flyes Chest exercise tutorial", + "category": "Chest" + }, + "declinepushup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Decline+Push-Up+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Decline+Push-Up+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Decline Push-Up Chest exercise tutorial", + "category": "Chest" + }, + "declinesmithpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Decline+Smith+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Decline+Smith+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Decline Smith Press Chest exercise tutorial", + "category": "Chest" + }, + "dipschestversion": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dips+-+Chest+Version+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dips+-+Chest+Version+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dips - Chest Version Chest exercise tutorial", + "category": "Chest" + }, + "droppush": { + "youtubeLink": "https://www.youtube.com/results?search_query=Drop+Push+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Drop+Push+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Drop Push Chest exercise tutorial", + "category": "Chest" + }, + "dumbbellbenchpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Bench+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Bench+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Bench Press Chest exercise tutorial", + "category": "Chest" + }, + "dumbbellbenchpresswithneutralgrip": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Bench+Press+with+Neutral+Grip+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Bench+Press+with+Neutral+Grip+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Bench Press with Neutral Grip Chest exercise tutorial", + "category": "Chest" + }, + "dumbbellflyes": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Flyes+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Flyes+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Flyes Chest exercise tutorial", + "category": "Chest" + }, + "dynamiccheststretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dynamic+Chest+Stretch+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dynamic+Chest+Stretch+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dynamic Chest Stretch Chest exercise tutorial", + "category": "Chest" + }, + "elbowsback": { + "youtubeLink": "https://www.youtube.com/results?search_query=Elbows+Back+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Elbows+Back+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Elbows Back Chest exercise tutorial", + "category": "Chest" + }, + "extendedrangeonearmkettlebellfloorpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Extended+Range+One-Arm+Kettlebell+Floor+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Extended+Range+One-Arm+Kettlebell+Floor+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Extended Range One-Arm Kettlebell Floor Press Chest exercise tutorial", + "category": "Chest" + }, + "flatbenchcableflyes": { + "youtubeLink": "https://www.youtube.com/results?search_query=Flat+Bench+Cable+Flyes+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Flat+Bench+Cable+Flyes+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Flat Bench Cable Flyes Chest exercise tutorial", + "category": "Chest" + }, + "forwarddragwithpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Forward+Drag+with+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Forward+Drag+with+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Forward Drag with Press Chest exercise tutorial", + "category": "Chest" + }, + "frontraiseandpullover": { + "youtubeLink": "https://www.youtube.com/results?search_query=Front+Raise+And+Pullover+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Front+Raise+And+Pullover+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Front Raise And Pullover Chest exercise tutorial", + "category": "Chest" + }, + "hammergripinclinedbbenchpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hammer+Grip+Incline+DB+Bench+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hammer+Grip+Incline+DB+Bench+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hammer Grip Incline DB Bench Press Chest exercise tutorial", + "category": "Chest" + }, + "heavybagthrust": { + "youtubeLink": "https://www.youtube.com/results?search_query=Heavy+Bag+Thrust+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Heavy+Bag+Thrust+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Heavy Bag Thrust Chest exercise tutorial", + "category": "Chest" + }, + "inclinecablechestpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Cable+Chest+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Cable+Chest+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Cable Chest Press Chest exercise tutorial", + "category": "Chest" + }, + "inclinecableflye": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Cable+Flye+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Cable+Flye+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Cable Flye Chest exercise tutorial", + "category": "Chest" + }, + "inclinedumbbellbenchwithpalmsfacingin": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Dumbbell+Bench+With+Palms+Facing+In+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Dumbbell+Bench+With+Palms+Facing+In+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Dumbbell Bench With Palms Facing In Chest exercise tutorial", + "category": "Chest" + }, + "inclinedumbbellflyes": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Dumbbell+Flyes+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Dumbbell+Flyes+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Dumbbell Flyes Chest exercise tutorial", + "category": "Chest" + }, + "inclinedumbbellflyeswithatwist": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Dumbbell+Flyes+-+With+A+Twist+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Dumbbell+Flyes+-+With+A+Twist+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Dumbbell Flyes - With A Twist Chest exercise tutorial", + "category": "Chest" + }, + "inclinedumbbellpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Dumbbell+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Dumbbell+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Dumbbell Press Chest exercise tutorial", + "category": "Chest" + }, + "inclinepushup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Push-Up+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Push-Up+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Push-Up Chest exercise tutorial", + "category": "Chest" + }, + "inclinepushupdepthjump": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Push-Up+Depth+Jump+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Push-Up+Depth+Jump+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Push-Up Depth Jump Chest exercise tutorial", + "category": "Chest" + }, + "inclinepushupmedium": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Push-Up+Medium+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Push-Up+Medium+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Push-Up Medium Chest exercise tutorial", + "category": "Chest" + }, + "inclinepushupreversegrip": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Push-Up+Reverse+Grip+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Push-Up+Reverse+Grip+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Push-Up Reverse Grip Chest exercise tutorial", + "category": "Chest" + }, + "inclinepushupwide": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Push-Up+Wide+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Push-Up+Wide+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Push-Up Wide Chest exercise tutorial", + "category": "Chest" + }, + "isometricchestsqueezes": { + "youtubeLink": "https://www.youtube.com/results?search_query=Isometric+Chest+Squeezes+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Isometric+Chest+Squeezes+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Isometric Chest Squeezes Chest exercise tutorial", + "category": "Chest" + }, + "isometricwipers": { + "youtubeLink": "https://www.youtube.com/results?search_query=Isometric+Wipers+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Isometric+Wipers+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Isometric Wipers Chest exercise tutorial", + "category": "Chest" + }, + "legoverfloorpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Leg-Over+Floor+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Leg-Over+Floor+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Leg-Over Floor Press Chest exercise tutorial", + "category": "Chest" + }, + "leveragechestpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Leverage+Chest+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Leverage+Chest+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Leverage Chest Press Chest exercise tutorial", + "category": "Chest" + }, + "leveragedeclinechestpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Leverage+Decline+Chest+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Leverage+Decline+Chest+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Leverage Decline Chest Press Chest exercise tutorial", + "category": "Chest" + }, + "leverageinclinechestpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Leverage+Incline+Chest+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Leverage+Incline+Chest+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Leverage Incline Chest Press Chest exercise tutorial", + "category": "Chest" + }, + "lowcablecrossover": { + "youtubeLink": "https://www.youtube.com/results?search_query=Low+Cable+Crossover+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Low+Cable+Crossover+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Low Cable Crossover Chest exercise tutorial", + "category": "Chest" + }, + "machinebenchpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Machine+Bench+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Machine+Bench+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Machine Bench Press Chest exercise tutorial", + "category": "Chest" + }, + "medicineballchestpass": { + "youtubeLink": "https://www.youtube.com/results?search_query=Medicine+Ball+Chest+Pass+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Medicine+Ball+Chest+Pass+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Medicine Ball Chest Pass Chest exercise tutorial", + "category": "Chest" + }, + "neckpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Neck+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Neck+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Neck Press Chest exercise tutorial", + "category": "Chest" + }, + "onearmdumbbellbenchpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=One+Arm+Dumbbell+Bench+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One+Arm+Dumbbell+Bench+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "One Arm Dumbbell Bench Press Chest exercise tutorial", + "category": "Chest" + }, + "onearmflatbenchdumbbellflye": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Flat+Bench+Dumbbell+Flye+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Flat+Bench+Dumbbell+Flye+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Flat Bench Dumbbell Flye Chest exercise tutorial", + "category": "Chest" + }, + "onearmkettlebellfloorpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Floor+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Floor+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Kettlebell Floor Press Chest exercise tutorial", + "category": "Chest" + }, + "plyokettlebellpushups": { + "youtubeLink": "https://www.youtube.com/results?search_query=Plyo+Kettlebell+Pushups+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Plyo+Kettlebell+Pushups+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Plyo Kettlebell Pushups Chest exercise tutorial", + "category": "Chest" + }, + "plyopushup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Plyo+Push-up+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Plyo+Push-up+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Plyo Push-up Chest exercise tutorial", + "category": "Chest" + }, + "pushuptosideplank": { + "youtubeLink": "https://www.youtube.com/results?search_query=Push+Up+to+Side+Plank+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Push+Up+to+Side+Plank+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Push Up to Side Plank Chest exercise tutorial", + "category": "Chest" + }, + "pushupwide": { + "youtubeLink": "https://www.youtube.com/results?search_query=Push-Up+Wide+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Push-Up+Wide+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Push-Up Wide Chest exercise tutorial", + "category": "Chest" + }, + "pushupswithfeetelevated": { + "youtubeLink": "https://www.youtube.com/results?search_query=Push-Ups+With+Feet+Elevated+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Push-Ups+With+Feet+Elevated+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Push-Ups With Feet Elevated Chest exercise tutorial", + "category": "Chest" + }, + "pushupswithfeetonanexerciseball": { + "youtubeLink": "https://www.youtube.com/results?search_query=Push-Ups+With+Feet+On+An+Exercise+Ball+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Push-Ups+With+Feet+On+An+Exercise+Ball+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Push-Ups With Feet On An Exercise Ball Chest exercise tutorial", + "category": "Chest" + }, + "pushups": { + "youtubeLink": "https://www.youtube.com/results?search_query=Pushups+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Pushups+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Pushups Chest exercise tutorial", + "category": "Chest" + }, + "pushupscloseandwidehandpositions": { + "youtubeLink": "https://www.youtube.com/results?search_query=Pushups+%28Close+and+Wide+Hand+Positions%29+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Pushups+%28Close+and+Wide+Hand+Positions%29+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Pushups (Close and Wide Hand Positions) Chest exercise tutorial", + "category": "Chest" + }, + "singlearmcablecrossover": { + "youtubeLink": "https://www.youtube.com/results?search_query=Single-Arm+Cable+Crossover+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Single-Arm+Cable+Crossover+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Single-Arm Cable Crossover Chest exercise tutorial", + "category": "Chest" + }, + "singlearmpushup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Single-Arm+Push-Up+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Single-Arm+Push-Up+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Single-Arm Push-Up Chest exercise tutorial", + "category": "Chest" + }, + "smithmachinebenchpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Bench+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Bench+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Bench Press Chest exercise tutorial", + "category": "Chest" + }, + "smithmachinedeclinepress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Decline+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Decline+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Decline Press Chest exercise tutorial", + "category": "Chest" + }, + "smithmachineinclinebenchpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Incline+Bench+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Incline+Bench+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Incline Bench Press Chest exercise tutorial", + "category": "Chest" + }, + "standingcablechestpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Cable+Chest+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Cable+Chest+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Cable Chest Press Chest exercise tutorial", + "category": "Chest" + }, + "straightarmdumbbellpullover": { + "youtubeLink": "https://www.youtube.com/results?search_query=Straight-Arm+Dumbbell+Pullover+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Straight-Arm+Dumbbell+Pullover+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Straight-Arm Dumbbell Pullover Chest exercise tutorial", + "category": "Chest" + }, + "suspendedpushup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Suspended+Push-Up+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Suspended+Push-Up+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Suspended Push-Up Chest exercise tutorial", + "category": "Chest" + }, + "svendpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Svend+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Svend+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Svend Press Chest exercise tutorial", + "category": "Chest" + }, + "widegripbarbellbenchpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Wide-Grip+Barbell+Bench+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Wide-Grip+Barbell+Bench+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Wide-Grip Barbell Bench Press Chest exercise tutorial", + "category": "Chest" + }, + "widegripdeclinebarbellbenchpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Wide-Grip+Decline+Barbell+Bench+Press+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Wide-Grip+Decline+Barbell+Bench+Press+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Wide-Grip Decline Barbell Bench Press Chest exercise tutorial", + "category": "Chest" + }, + "widegripdeclinebarbellpullover": { + "youtubeLink": "https://www.youtube.com/results?search_query=Wide-Grip+Decline+Barbell+Pullover+Chest+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Wide-Grip+Decline+Barbell+Pullover+Chest+exercise+tutorial+shorts", + "youtubeSearchQuery": "Wide-Grip Decline Barbell Pullover Chest exercise tutorial", + "category": "Chest" + }, + "bottomsupcleanfromthehangposition": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bottoms-Up+Clean+From+The+Hang+Position+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bottoms-Up+Clean+From+The+Hang+Position+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bottoms-Up Clean From The Hang Position Forearms exercise tutorial", + "category": "Forearms" + }, + "cablewristcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Wrist+Curl+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Wrist+Curl+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Wrist Curl Forearms exercise tutorial", + "category": "Forearms" + }, + "dumbbelllyingpronation": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Lying+Pronation+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Lying+Pronation+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Lying Pronation Forearms exercise tutorial", + "category": "Forearms" + }, + "dumbbelllyingsupination": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Lying+Supination+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Lying+Supination+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Lying Supination Forearms exercise tutorial", + "category": "Forearms" + }, + "farmerswalk": { + "youtubeLink": "https://www.youtube.com/results?search_query=Farmer%27s+Walk+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Farmer%27s+Walk+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Farmer's Walk Forearms exercise tutorial", + "category": "Forearms" + }, + "fingercurls": { + "youtubeLink": "https://www.youtube.com/results?search_query=Finger+Curls+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Finger+Curls+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Finger Curls Forearms exercise tutorial", + "category": "Forearms" + }, + "kneelingforearmstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kneeling+Forearm+Stretch+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kneeling+Forearm+Stretch+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kneeling Forearm Stretch Forearms exercise tutorial", + "category": "Forearms" + }, + "palmsdowndumbbellwristcurloverabench": { + "youtubeLink": "https://www.youtube.com/results?search_query=Palms-Down+Dumbbell+Wrist+Curl+Over+A+Bench+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Palms-Down+Dumbbell+Wrist+Curl+Over+A+Bench+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Palms-Down Dumbbell Wrist Curl Over A Bench Forearms exercise tutorial", + "category": "Forearms" + }, + "palmsdownwristcurloverabench": { + "youtubeLink": "https://www.youtube.com/results?search_query=Palms-Down+Wrist+Curl+Over+A+Bench+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Palms-Down+Wrist+Curl+Over+A+Bench+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Palms-Down Wrist Curl Over A Bench Forearms exercise tutorial", + "category": "Forearms" + }, + "palmsupbarbellwristcurloverabench": { + "youtubeLink": "https://www.youtube.com/results?search_query=Palms-Up+Barbell+Wrist+Curl+Over+A+Bench+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Palms-Up+Barbell+Wrist+Curl+Over+A+Bench+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Palms-Up Barbell Wrist Curl Over A Bench Forearms exercise tutorial", + "category": "Forearms" + }, + "palmsupdumbbellwristcurloverabench": { + "youtubeLink": "https://www.youtube.com/results?search_query=Palms-Up+Dumbbell+Wrist+Curl+Over+A+Bench+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Palms-Up+Dumbbell+Wrist+Curl+Over+A+Bench+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Palms-Up Dumbbell Wrist Curl Over A Bench Forearms exercise tutorial", + "category": "Forearms" + }, + "platepinch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Plate+Pinch+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Plate+Pinch+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Plate Pinch Forearms exercise tutorial", + "category": "Forearms" + }, + "rickshawcarry": { + "youtubeLink": "https://www.youtube.com/results?search_query=Rickshaw+Carry+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Rickshaw+Carry+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Rickshaw Carry Forearms exercise tutorial", + "category": "Forearms" + }, + "seateddumbbellpalmsdownwristcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Dumbbell+Palms-Down+Wrist+Curl+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Dumbbell+Palms-Down+Wrist+Curl+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Dumbbell Palms-Down Wrist Curl Forearms exercise tutorial", + "category": "Forearms" + }, + "seateddumbbellpalmsupwristcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Dumbbell+Palms-Up+Wrist+Curl+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Dumbbell+Palms-Up+Wrist+Curl+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Dumbbell Palms-Up Wrist Curl Forearms exercise tutorial", + "category": "Forearms" + }, + "seatedonearmdumbbellpalmsdownwristcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+One-Arm+Dumbbell+Palms-Down+Wrist+Curl+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+One-Arm+Dumbbell+Palms-Down+Wrist+Curl+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated One-Arm Dumbbell Palms-Down Wrist Curl Forearms exercise tutorial", + "category": "Forearms" + }, + "seatedonearmdumbbellpalmsupwristcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+One-Arm+Dumbbell+Palms-Up+Wrist+Curl+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+One-Arm+Dumbbell+Palms-Up+Wrist+Curl+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated One-Arm Dumbbell Palms-Up Wrist Curl Forearms exercise tutorial", + "category": "Forearms" + }, + "seatedpalmupbarbellwristcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Palm-Up+Barbell+Wrist+Curl+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Palm-Up+Barbell+Wrist+Curl+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Palm-Up Barbell Wrist Curl Forearms exercise tutorial", + "category": "Forearms" + }, + "seatedpalmsdownbarbellwristcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Palms-Down+Barbell+Wrist+Curl+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Palms-Down+Barbell+Wrist+Curl+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Palms-Down Barbell Wrist Curl Forearms exercise tutorial", + "category": "Forearms" + }, + "seatedtwoarmpalmsuplowpulleywristcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Two-Arm+Palms-Up+Low-Pulley+Wrist+Curl+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Two-Arm+Palms-Up+Low-Pulley+Wrist+Curl+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Two-Arm Palms-Up Low-Pulley Wrist Curl Forearms exercise tutorial", + "category": "Forearms" + }, + "standingolympicplatehandsqueeze": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Olympic+Plate+Hand+Squeeze+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Olympic+Plate+Hand+Squeeze+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Olympic Plate Hand Squeeze Forearms exercise tutorial", + "category": "Forearms" + }, + "standingpalmsupbarbellbehindthebackwristcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Palms-Up+Barbell+Behind+The+Back+Wrist+Curl+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Palms-Up+Barbell+Behind+The+Back+Wrist+Curl+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Palms-Up Barbell Behind The Back Wrist Curl Forearms exercise tutorial", + "category": "Forearms" + }, + "wristcircles": { + "youtubeLink": "https://www.youtube.com/results?search_query=Wrist+Circles+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Wrist+Circles+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Wrist Circles Forearms exercise tutorial", + "category": "Forearms" + }, + "wristroller": { + "youtubeLink": "https://www.youtube.com/results?search_query=Wrist+Roller+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Wrist+Roller+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Wrist Roller Forearms exercise tutorial", + "category": "Forearms" + }, + "wristrotationswithstraightbar": { + "youtubeLink": "https://www.youtube.com/results?search_query=Wrist+Rotations+with+Straight+Bar+Forearms+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Wrist+Rotations+with+Straight+Bar+Forearms+exercise+tutorial+shorts", + "youtubeSearchQuery": "Wrist Rotations with Straight Bar Forearms exercise tutorial", + "category": "Forearms" + }, + "ankleontheknee": { + "youtubeLink": "https://www.youtube.com/results?search_query=Ankle+On+The+Knee+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Ankle+On+The+Knee+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Ankle On The Knee Glutes exercise tutorial", + "category": "Glutes" + }, + "barbellglutebridge": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Glute+Bridge+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Glute+Bridge+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Glute Bridge Glutes exercise tutorial", + "category": "Glutes" + }, + "barbellhipthrust": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Hip+Thrust+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Hip+Thrust+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Hip Thrust Glutes exercise tutorial", + "category": "Glutes" + }, + "buttliftbridge": { + "youtubeLink": "https://www.youtube.com/results?search_query=Butt+Lift+%28Bridge%29+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Butt+Lift+%28Bridge%29+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Butt Lift (Bridge) Glutes exercise tutorial", + "category": "Glutes" + }, + "downwardfacingbalance": { + "youtubeLink": "https://www.youtube.com/results?search_query=Downward+Facing+Balance+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Downward+Facing+Balance+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Downward Facing Balance Glutes exercise tutorial", + "category": "Glutes" + }, + "flutterkicks": { + "youtubeLink": "https://www.youtube.com/results?search_query=Flutter+Kicks+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Flutter+Kicks+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Flutter Kicks Glutes exercise tutorial", + "category": "Glutes" + }, + "glutekickback": { + "youtubeLink": "https://www.youtube.com/results?search_query=Glute+Kickback+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Glute+Kickback+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Glute Kickback Glutes exercise tutorial", + "category": "Glutes" + }, + "hipextensionwithbands": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hip+Extension+with+Bands+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hip+Extension+with+Bands+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hip Extension with Bands Glutes exercise tutorial", + "category": "Glutes" + }, + "hipliftwithband": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hip+Lift+with+Band+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hip+Lift+with+Band+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hip Lift with Band Glutes exercise tutorial", + "category": "Glutes" + }, + "kneeacrossthebody": { + "youtubeLink": "https://www.youtube.com/results?search_query=Knee+Across+The+Body+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Knee+Across+The+Body+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Knee Across The Body Glutes exercise tutorial", + "category": "Glutes" + }, + "kneelingjumpsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kneeling+Jump+Squat+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kneeling+Jump+Squat+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kneeling Jump Squat Glutes exercise tutorial", + "category": "Glutes" + }, + "kneelingsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kneeling+Squat+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kneeling+Squat+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kneeling Squat Glutes exercise tutorial", + "category": "Glutes" + }, + "leglift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Leg+Lift+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Leg+Lift+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Leg Lift Glutes exercise tutorial", + "category": "Glutes" + }, + "lyingglute": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Glute+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Glute+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Glute Glutes exercise tutorial", + "category": "Glutes" + }, + "onekneetochest": { + "youtubeLink": "https://www.youtube.com/results?search_query=One+Knee+To+Chest+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One+Knee+To+Chest+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "One Knee To Chest Glutes exercise tutorial", + "category": "Glutes" + }, + "oneleggedcablekickback": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Legged+Cable+Kickback+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Legged+Cable+Kickback+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Legged Cable Kickback Glutes exercise tutorial", + "category": "Glutes" + }, + "physioballhipbridge": { + "youtubeLink": "https://www.youtube.com/results?search_query=Physioball+Hip+Bridge+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Physioball+Hip+Bridge+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Physioball Hip Bridge Glutes exercise tutorial", + "category": "Glutes" + }, + "piriformissmr": { + "youtubeLink": "https://www.youtube.com/results?search_query=Piriformis-SMR+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Piriformis-SMR+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Piriformis-SMR Glutes exercise tutorial", + "category": "Glutes" + }, + "pullthrough": { + "youtubeLink": "https://www.youtube.com/results?search_query=Pull+Through+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Pull+Through+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Pull Through Glutes exercise tutorial", + "category": "Glutes" + }, + "seatedglute": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Glute+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Glute+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Glute Glutes exercise tutorial", + "category": "Glutes" + }, + "singlelegglutebridge": { + "youtubeLink": "https://www.youtube.com/results?search_query=Single+Leg+Glute+Bridge+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Single+Leg+Glute+Bridge+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Single Leg Glute Bridge Glutes exercise tutorial", + "category": "Glutes" + }, + "stepupwithkneeraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Step-up+with+Knee+Raise+Glutes+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Step-up+with+Knee+Raise+Glutes+exercise+tutorial+shorts", + "youtubeSearchQuery": "Step-up with Knee Raise Glutes exercise tutorial", + "category": "Glutes" + }, + "9090hamstring": { + "youtubeLink": "https://www.youtube.com/results?search_query=90%2F90+Hamstring+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=90%2F90+Hamstring+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "90/90 Hamstring Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "alternatinghangclean": { + "youtubeLink": "https://www.youtube.com/results?search_query=Alternating+Hang+Clean+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Alternating+Hang+Clean+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Alternating Hang Clean Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "balllegcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Ball+Leg+Curl+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Ball+Leg+Curl+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Ball Leg Curl Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "bandgoodmorning": { + "youtubeLink": "https://www.youtube.com/results?search_query=Band+Good+Morning+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Band+Good+Morning+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Band Good Morning Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "bandgoodmorningpullthrough": { + "youtubeLink": "https://www.youtube.com/results?search_query=Band+Good+Morning+%28Pull+Through%29+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Band+Good+Morning+%28Pull+Through%29+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Band Good Morning (Pull Through) Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "boxjumpmultipleresponse": { + "youtubeLink": "https://www.youtube.com/results?search_query=Box+Jump+%28Multiple+Response%29+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Box+Jump+%28Multiple+Response%29+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Box Jump (Multiple Response) Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "boxskip": { + "youtubeLink": "https://www.youtube.com/results?search_query=Box+Skip+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Box+Skip+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Box Skip Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "chairlegextendedstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Chair+Leg+Extended+Stretch+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Chair+Leg+Extended+Stretch+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Chair Leg Extended Stretch Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "clean": { + "youtubeLink": "https://www.youtube.com/results?search_query=Clean+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Clean+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Clean Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "cleandeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Clean+Deadlift+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Clean+Deadlift+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Clean Deadlift Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "doublekettlebellalternatinghangclean": { + "youtubeLink": "https://www.youtube.com/results?search_query=Double+Kettlebell+Alternating+Hang+Clean+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Double+Kettlebell+Alternating+Hang+Clean+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Double Kettlebell Alternating Hang Clean Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "dumbbellclean": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Clean+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Clean+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Clean Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "floorglutehamraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Floor+Glute-Ham+Raise+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Floor+Glute-Ham+Raise+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Floor Glute-Ham Raise Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "frontboxjump": { + "youtubeLink": "https://www.youtube.com/results?search_query=Front+Box+Jump+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Front+Box+Jump+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Front Box Jump Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "frontlegraises": { + "youtubeLink": "https://www.youtube.com/results?search_query=Front+Leg+Raises+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Front+Leg+Raises+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Front Leg Raises Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "glutehamraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Glute+Ham+Raise+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Glute+Ham+Raise+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Glute Ham Raise Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "goodmorning": { + "youtubeLink": "https://www.youtube.com/results?search_query=Good+Morning+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Good+Morning+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Good Morning Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "goodmorningoffpins": { + "youtubeLink": "https://www.youtube.com/results?search_query=Good+Morning+off+Pins+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Good+Morning+off+Pins+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Good Morning off Pins Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "hamstringstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hamstring+Stretch+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hamstring+Stretch+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hamstring Stretch Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "hamstringsmr": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hamstring-SMR+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hamstring-SMR+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hamstring-SMR Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "hangsnatch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hang+Snatch+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hang+Snatch+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hang Snatch Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "hangsnatchbelowknees": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hang+Snatch+-+Below+Knees+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hang+Snatch+-+Below+Knees+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hang Snatch - Below Knees Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "hangingbargoodmorning": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hanging+Bar+Good+Morning+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hanging+Bar+Good+Morning+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hanging Bar Good Morning Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "hurdlehops": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hurdle+Hops+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hurdle+Hops+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hurdle Hops Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "inchworm": { + "youtubeLink": "https://www.youtube.com/results?search_query=Inchworm+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Inchworm+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Inchworm Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "intermediategroinstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Intermediate+Groin+Stretch+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Intermediate+Groin+Stretch+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Intermediate Groin Stretch Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "kettlebelldeadclean": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kettlebell+Dead+Clean+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kettlebell+Dead+Clean+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kettlebell Dead Clean Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "kettlebellhangclean": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kettlebell+Hang+Clean+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kettlebell+Hang+Clean+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kettlebell Hang Clean Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "kettlebelloneleggeddeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kettlebell+One-Legged+Deadlift+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kettlebell+One-Legged+Deadlift+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kettlebell One-Legged Deadlift Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "kneetuckjump": { + "youtubeLink": "https://www.youtube.com/results?search_query=Knee+Tuck+Jump+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Knee+Tuck+Jump+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Knee Tuck Jump Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "leguphamstringstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Leg-Up+Hamstring+Stretch+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Leg-Up+Hamstring+Stretch+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Leg-Up Hamstring Stretch Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "linear3partstarttechnique": { + "youtubeLink": "https://www.youtube.com/results?search_query=Linear+3-Part+Start+Technique+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Linear+3-Part+Start+Technique+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Linear 3-Part Start Technique Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "linearaccelerationwalldrill": { + "youtubeLink": "https://www.youtube.com/results?search_query=Linear+Acceleration+Wall+Drill+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Linear+Acceleration+Wall+Drill+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Linear Acceleration Wall Drill Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "lungepassthrough": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lunge+Pass+Through+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lunge+Pass+Through+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lunge Pass Through Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "lyinghamstring": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Hamstring+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Hamstring+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Hamstring Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "lyinglegcurls": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Leg+Curls+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Leg+Curls+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Leg Curls Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "movingclawseries": { + "youtubeLink": "https://www.youtube.com/results?search_query=Moving+Claw+Series+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Moving+Claw+Series+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Moving Claw Series Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "musclesnatch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Muscle+Snatch+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Muscle+Snatch+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Muscle Snatch Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "naturalglutehamraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Natural+Glute+Ham+Raise+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Natural+Glute+Ham+Raise+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Natural Glute Ham Raise Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "onearmkettlebellclean": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Clean+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Clean+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Kettlebell Clean Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "onearmkettlebellswings": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Swings+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Swings+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Kettlebell Swings Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "onearmopenpalmkettlebellclean": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Open+Palm+Kettlebell+Clean+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Open+Palm+Kettlebell+Clean+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Open Palm Kettlebell Clean Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "openpalmkettlebellclean": { + "youtubeLink": "https://www.youtube.com/results?search_query=Open+Palm+Kettlebell+Clean+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Open+Palm+Kettlebell+Clean+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Open Palm Kettlebell Clean Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "platformhamstringslides": { + "youtubeLink": "https://www.youtube.com/results?search_query=Platform+Hamstring+Slides+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Platform+Hamstring+Slides+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Platform Hamstring Slides Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "powerclean": { + "youtubeLink": "https://www.youtube.com/results?search_query=Power+Clean+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Power+Clean+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Power Clean Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "powercleanfromblocks": { + "youtubeLink": "https://www.youtube.com/results?search_query=Power+Clean+from+Blocks+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Power+Clean+from+Blocks+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Power Clean from Blocks Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "powersnatch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Power+Snatch+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Power+Snatch+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Power Snatch Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "powerstairs": { + "youtubeLink": "https://www.youtube.com/results?search_query=Power+Stairs+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Power+Stairs+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Power Stairs Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "pronemanualhamstring": { + "youtubeLink": "https://www.youtube.com/results?search_query=Prone+Manual+Hamstring+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Prone+Manual+Hamstring+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Prone Manual Hamstring Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "prowlersprint": { + "youtubeLink": "https://www.youtube.com/results?search_query=Prowler+Sprint+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Prowler+Sprint+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Prowler Sprint Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "reversebandsumodeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Band+Sumo+Deadlift+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Band+Sumo+Deadlift+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Band Sumo Deadlift Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "reversehyperextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Hyperextension+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Hyperextension+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Hyperextension Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "romaniandeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Romanian+Deadlift+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Romanian+Deadlift+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Romanian Deadlift Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "romaniandeadliftfromdeficit": { + "youtubeLink": "https://www.youtube.com/results?search_query=Romanian+Deadlift+from+Deficit+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Romanian+Deadlift+from+Deficit+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Romanian Deadlift from Deficit Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "runnersstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Runner%27s+Stretch+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Runner%27s+Stretch+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Runner's Stretch Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "seatedbandhamstringcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Band+Hamstring+Curl+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Band+Hamstring+Curl+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Band Hamstring Curl Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "seatedfloorhamstringstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Floor+Hamstring+Stretch+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Floor+Hamstring+Stretch+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Floor Hamstring Stretch Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "seatedhamstring": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Hamstring+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Hamstring+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Hamstring Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "seatedhamstringandcalfstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Hamstring+and+Calf+Stretch+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Hamstring+and+Calf+Stretch+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Hamstring and Calf Stretch Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "seatedlegcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Leg+Curl+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Leg+Curl+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Leg Curl Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "smithmachinehangpowerclean": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Hang+Power+Clean+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Hang+Power+Clean+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Hang Power Clean Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "smithmachinestiffleggeddeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Stiff-Legged+Deadlift+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Stiff-Legged+Deadlift+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Stiff-Legged Deadlift Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "snatchdeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Snatch+Deadlift+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Snatch+Deadlift+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Snatch Deadlift Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "snatchpull": { + "youtubeLink": "https://www.youtube.com/results?search_query=Snatch+Pull+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Snatch+Pull+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Snatch Pull Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "splitsnatch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Split+Snatch+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Split+Snatch+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Split Snatch Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "splitsquats": { + "youtubeLink": "https://www.youtube.com/results?search_query=Split+Squats+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Split+Squats+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Split Squats Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "standinghamstringandcalfstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Hamstring+and+Calf+Stretch+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Hamstring+and+Calf+Stretch+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Hamstring and Calf Stretch Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "standinglegcurl": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Leg+Curl+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Leg+Curl+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Leg Curl Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "standingtoetouches": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Toe+Touches+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Toe+Touches+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Toe Touches Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "stiffleggedbarbelldeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Stiff-Legged+Barbell+Deadlift+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Stiff-Legged+Barbell+Deadlift+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Stiff-Legged Barbell Deadlift Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "stiffleggeddumbbelldeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Stiff-Legged+Dumbbell+Deadlift+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Stiff-Legged+Dumbbell+Deadlift+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Stiff-Legged Dumbbell Deadlift Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "sumodeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Sumo+Deadlift+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Sumo+Deadlift+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Sumo Deadlift Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "sumodeadliftwithbands": { + "youtubeLink": "https://www.youtube.com/results?search_query=Sumo+Deadlift+with+Bands+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Sumo+Deadlift+with+Bands+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Sumo Deadlift with Bands Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "sumodeadliftwithchains": { + "youtubeLink": "https://www.youtube.com/results?search_query=Sumo+Deadlift+with+Chains+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Sumo+Deadlift+with+Chains+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Sumo Deadlift with Chains Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "thestraddle": { + "youtubeLink": "https://www.youtube.com/results?search_query=The+Straddle+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=The+Straddle+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "The Straddle Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "upperbackleggrab": { + "youtubeLink": "https://www.youtube.com/results?search_query=Upper+Back-Leg+Grab+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Upper+Back-Leg+Grab+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Upper Back-Leg Grab Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "verticalswing": { + "youtubeLink": "https://www.youtube.com/results?search_query=Vertical+Swing+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Vertical+Swing+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Vertical Swing Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "widestancestifflegs": { + "youtubeLink": "https://www.youtube.com/results?search_query=Wide+Stance+Stiff+Legs+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Wide+Stance+Stiff+Legs+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "Wide Stance Stiff Legs Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "worldsgreateststretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=World%27s+Greatest+Stretch+Hamstrings+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=World%27s+Greatest+Stretch+Hamstrings+exercise+tutorial+shorts", + "youtubeSearchQuery": "World's Greatest Stretch Hamstrings exercise tutorial", + "category": "Hamstrings" + }, + "bandassistedpullup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Band+Assisted+Pull-Up+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Band+Assisted+Pull-Up+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Band Assisted Pull-Up Lats exercise tutorial", + "category": "Lats" + }, + "bentarmbarbellpullover": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bent-Arm+Barbell+Pullover+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bent-Arm+Barbell+Pullover+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bent-Arm Barbell Pullover Lats exercise tutorial", + "category": "Lats" + }, + "cableinclinepushdown": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Incline+Pushdown+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Incline+Pushdown+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Incline Pushdown Lats exercise tutorial", + "category": "Lats" + }, + "catchandoverheadthrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Catch+and+Overhead+Throw+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Catch+and+Overhead+Throw+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Catch and Overhead Throw Lats exercise tutorial", + "category": "Lats" + }, + "chairlowerbackstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Chair+Lower+Back+Stretch+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Chair+Lower+Back+Stretch+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Chair Lower Back Stretch Lats exercise tutorial", + "category": "Lats" + }, + "chinup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Chin-Up+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Chin-Up+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Chin-Up Lats exercise tutorial", + "category": "Lats" + }, + "closegripfrontlatpulldown": { + "youtubeLink": "https://www.youtube.com/results?search_query=Close-Grip+Front+Lat+Pulldown+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Close-Grip+Front+Lat+Pulldown+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Close-Grip Front Lat Pulldown Lats exercise tutorial", + "category": "Lats" + }, + "dynamicbackstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dynamic+Back+Stretch+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dynamic+Back+Stretch+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dynamic Back Stretch Lats exercise tutorial", + "category": "Lats" + }, + "elevatedcablerows": { + "youtubeLink": "https://www.youtube.com/results?search_query=Elevated+Cable+Rows+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Elevated+Cable+Rows+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Elevated Cable Rows Lats exercise tutorial", + "category": "Lats" + }, + "fullrangeofmotionlatpulldown": { + "youtubeLink": "https://www.youtube.com/results?search_query=Full+Range-Of-Motion+Lat+Pulldown+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Full+Range-Of-Motion+Lat+Pulldown+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Full Range-Of-Motion Lat Pulldown Lats exercise tutorial", + "category": "Lats" + }, + "girondasternumchins": { + "youtubeLink": "https://www.youtube.com/results?search_query=Gironda+Sternum+Chins+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Gironda+Sternum+Chins+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Gironda Sternum Chins Lats exercise tutorial", + "category": "Lats" + }, + "kippingmuscleup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kipping+Muscle+Up+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kipping+Muscle+Up+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kipping Muscle Up Lats exercise tutorial", + "category": "Lats" + }, + "kneelinghighpulleyrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kneeling+High+Pulley+Row+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kneeling+High+Pulley+Row+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kneeling High Pulley Row Lats exercise tutorial", + "category": "Lats" + }, + "kneelingsinglearmhighpulleyrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kneeling+Single-Arm+High+Pulley+Row+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kneeling+Single-Arm+High+Pulley+Row+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kneeling Single-Arm High Pulley Row Lats exercise tutorial", + "category": "Lats" + }, + "latissimusdorsismr": { + "youtubeLink": "https://www.youtube.com/results?search_query=Latissimus+Dorsi-SMR+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Latissimus+Dorsi-SMR+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Latissimus Dorsi-SMR Lats exercise tutorial", + "category": "Lats" + }, + "leverageisorow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Leverage+Iso+Row+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Leverage+Iso+Row+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Leverage Iso Row Lats exercise tutorial", + "category": "Lats" + }, + "londonbridges": { + "youtubeLink": "https://www.youtube.com/results?search_query=London+Bridges+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=London+Bridges+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "London Bridges Lats exercise tutorial", + "category": "Lats" + }, + "muscleup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Muscle+Up+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Muscle+Up+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Muscle Up Lats exercise tutorial", + "category": "Lats" + }, + "onearmagainstwall": { + "youtubeLink": "https://www.youtube.com/results?search_query=One+Arm+Against+Wall+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One+Arm+Against+Wall+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "One Arm Against Wall Lats exercise tutorial", + "category": "Lats" + }, + "onearmlatpulldown": { + "youtubeLink": "https://www.youtube.com/results?search_query=One+Arm+Lat+Pulldown+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One+Arm+Lat+Pulldown+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "One Arm Lat Pulldown Lats exercise tutorial", + "category": "Lats" + }, + "onehandedhang": { + "youtubeLink": "https://www.youtube.com/results?search_query=One+Handed+Hang+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One+Handed+Hang+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "One Handed Hang Lats exercise tutorial", + "category": "Lats" + }, + "overheadlat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Overhead+Lat+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Overhead+Lat+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Overhead Lat Lats exercise tutorial", + "category": "Lats" + }, + "overheadslam": { + "youtubeLink": "https://www.youtube.com/results?search_query=Overhead+Slam+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Overhead+Slam+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Overhead Slam Lats exercise tutorial", + "category": "Lats" + }, + "pullups": { + "youtubeLink": "https://www.youtube.com/results?search_query=Pullups+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Pullups+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Pullups Lats exercise tutorial", + "category": "Lats" + }, + "rockypullupspulldowns": { + "youtubeLink": "https://www.youtube.com/results?search_query=Rocky+Pull-Ups%2FPulldowns+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Rocky+Pull-Ups%2FPulldowns+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Rocky Pull-Ups/Pulldowns Lats exercise tutorial", + "category": "Lats" + }, + "ropeclimb": { + "youtubeLink": "https://www.youtube.com/results?search_query=Rope+Climb+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Rope+Climb+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Rope Climb Lats exercise tutorial", + "category": "Lats" + }, + "ropestraightarmpulldown": { + "youtubeLink": "https://www.youtube.com/results?search_query=Rope+Straight-Arm+Pulldown+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Rope+Straight-Arm+Pulldown+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Rope Straight-Arm Pulldown Lats exercise tutorial", + "category": "Lats" + }, + "shotgunrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Shotgun+Row+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Shotgun+Row+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Shotgun Row Lats exercise tutorial", + "category": "Lats" + }, + "sidetosidechins": { + "youtubeLink": "https://www.youtube.com/results?search_query=Side+To+Side+Chins+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Side+To+Side+Chins+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Side To Side Chins Lats exercise tutorial", + "category": "Lats" + }, + "sidelyingfloorstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Side-Lying+Floor+Stretch+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Side-Lying+Floor+Stretch+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Side-Lying Floor Stretch Lats exercise tutorial", + "category": "Lats" + }, + "straightarmpulldown": { + "youtubeLink": "https://www.youtube.com/results?search_query=Straight-Arm+Pulldown+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Straight-Arm+Pulldown+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Straight-Arm Pulldown Lats exercise tutorial", + "category": "Lats" + }, + "underhandcablepulldowns": { + "youtubeLink": "https://www.youtube.com/results?search_query=Underhand+Cable+Pulldowns+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Underhand+Cable+Pulldowns+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Underhand Cable Pulldowns Lats exercise tutorial", + "category": "Lats" + }, + "vbarpulldown": { + "youtubeLink": "https://www.youtube.com/results?search_query=V-Bar+Pulldown+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=V-Bar+Pulldown+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "V-Bar Pulldown Lats exercise tutorial", + "category": "Lats" + }, + "vbarpullup": { + "youtubeLink": "https://www.youtube.com/results?search_query=V-Bar+Pullup+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=V-Bar+Pullup+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "V-Bar Pullup Lats exercise tutorial", + "category": "Lats" + }, + "weightedpullups": { + "youtubeLink": "https://www.youtube.com/results?search_query=Weighted+Pull+Ups+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Weighted+Pull+Ups+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Weighted Pull Ups Lats exercise tutorial", + "category": "Lats" + }, + "widegriplatpulldown": { + "youtubeLink": "https://www.youtube.com/results?search_query=Wide-Grip+Lat+Pulldown+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Wide-Grip+Lat+Pulldown+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Wide-Grip Lat Pulldown Lats exercise tutorial", + "category": "Lats" + }, + "widegrippulldownbehindtheneck": { + "youtubeLink": "https://www.youtube.com/results?search_query=Wide-Grip+Pulldown+Behind+The+Neck+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Wide-Grip+Pulldown+Behind+The+Neck+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Wide-Grip Pulldown Behind The Neck Lats exercise tutorial", + "category": "Lats" + }, + "widegriprearpullup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Wide-Grip+Rear+Pull-Up+Lats+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Wide-Grip+Rear+Pull-Up+Lats+exercise+tutorial+shorts", + "youtubeSearchQuery": "Wide-Grip Rear Pull-Up Lats exercise tutorial", + "category": "Lats" + }, + "atlasstonetrainer": { + "youtubeLink": "https://www.youtube.com/results?search_query=Atlas+Stone+Trainer+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Atlas+Stone+Trainer+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Atlas Stone Trainer Lower back exercise tutorial", + "category": "Lower back" + }, + "atlasstones": { + "youtubeLink": "https://www.youtube.com/results?search_query=Atlas+Stones+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Atlas+Stones+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Atlas Stones Lower back exercise tutorial", + "category": "Lower back" + }, + "axledeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Axle+Deadlift+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Axle+Deadlift+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Axle Deadlift Lower back exercise tutorial", + "category": "Lower back" + }, + "barbelldeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Deadlift+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Deadlift+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Deadlift Lower back exercise tutorial", + "category": "Lower back" + }, + "catstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cat+Stretch+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cat+Stretch+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cat Stretch Lower back exercise tutorial", + "category": "Lower back" + }, + "childspose": { + "youtubeLink": "https://www.youtube.com/results?search_query=Child%27s+Pose+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Child%27s+Pose+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Child's Pose Lower back exercise tutorial", + "category": "Lower back" + }, + "crossoverreverselunge": { + "youtubeLink": "https://www.youtube.com/results?search_query=Crossover+Reverse+Lunge+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Crossover+Reverse+Lunge+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Crossover Reverse Lunge Lower back exercise tutorial", + "category": "Lower back" + }, + "dancersstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dancer%27s+Stretch+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dancer%27s+Stretch+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dancer's Stretch Lower back exercise tutorial", + "category": "Lower back" + }, + "deadliftwithbands": { + "youtubeLink": "https://www.youtube.com/results?search_query=Deadlift+with+Bands+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Deadlift+with+Bands+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Deadlift with Bands Lower back exercise tutorial", + "category": "Lower back" + }, + "deadliftwithchains": { + "youtubeLink": "https://www.youtube.com/results?search_query=Deadlift+with+Chains+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Deadlift+with+Chains+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Deadlift with Chains Lower back exercise tutorial", + "category": "Lower back" + }, + "deficitdeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Deficit+Deadlift+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Deficit+Deadlift+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Deficit Deadlift Lower back exercise tutorial", + "category": "Lower back" + }, + "hugaball": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hug+A+Ball+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hug+A+Ball+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hug A Ball Lower back exercise tutorial", + "category": "Lower back" + }, + "hugkneestochest": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hug+Knees+To+Chest+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hug+Knees+To+Chest+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hug Knees To Chest Lower back exercise tutorial", + "category": "Lower back" + }, + "hyperextensionsbackextensions": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hyperextensions+%28Back+Extensions%29+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hyperextensions+%28Back+Extensions%29+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hyperextensions (Back Extensions) Lower back exercise tutorial", + "category": "Lower back" + }, + "hyperextensionswithnohyperextensionbench": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hyperextensions+With+No+Hyperextension+Bench+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hyperextensions+With+No+Hyperextension+Bench+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hyperextensions With No Hyperextension Bench Lower back exercise tutorial", + "category": "Lower back" + }, + "kegload": { + "youtubeLink": "https://www.youtube.com/results?search_query=Keg+Load+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Keg+Load+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Keg Load Lower back exercise tutorial", + "category": "Lower back" + }, + "lowerbacksmr": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lower+Back-SMR+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lower+Back-SMR+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lower Back-SMR Lower back exercise tutorial", + "category": "Lower back" + }, + "pelvictiltintobridge": { + "youtubeLink": "https://www.youtube.com/results?search_query=Pelvic+Tilt+Into+Bridge+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Pelvic+Tilt+Into+Bridge+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Pelvic Tilt Into Bridge Lower back exercise tutorial", + "category": "Lower back" + }, + "pyramid": { + "youtubeLink": "https://www.youtube.com/results?search_query=Pyramid+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Pyramid+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Pyramid Lower back exercise tutorial", + "category": "Lower back" + }, + "rackpullwithbands": { + "youtubeLink": "https://www.youtube.com/results?search_query=Rack+Pull+with+Bands+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Rack+Pull+with+Bands+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Rack Pull with Bands Lower back exercise tutorial", + "category": "Lower back" + }, + "rackpulls": { + "youtubeLink": "https://www.youtube.com/results?search_query=Rack+Pulls+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Rack+Pulls+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Rack Pulls Lower back exercise tutorial", + "category": "Lower back" + }, + "reversebanddeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Band+Deadlift+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Band+Deadlift+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Band Deadlift Lower back exercise tutorial", + "category": "Lower back" + }, + "seatedgoodmornings": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Good+Mornings+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Good+Mornings+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Good Mornings Lower back exercise tutorial", + "category": "Lower back" + }, + "standingpelvictilt": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Pelvic+Tilt+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Pelvic+Tilt+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Pelvic Tilt Lower back exercise tutorial", + "category": "Lower back" + }, + "stifflegbarbellgoodmorning": { + "youtubeLink": "https://www.youtube.com/results?search_query=Stiff+Leg+Barbell+Good+Morning+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Stiff+Leg+Barbell+Good+Morning+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Stiff Leg Barbell Good Morning Lower back exercise tutorial", + "category": "Lower back" + }, + "superman": { + "youtubeLink": "https://www.youtube.com/results?search_query=Superman+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Superman+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Superman Lower back exercise tutorial", + "category": "Lower back" + }, + "weightedballhyperextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Weighted+Ball+Hyperextension+Lower+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Weighted+Ball+Hyperextension+Lower+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Weighted Ball Hyperextension Lower back exercise tutorial", + "category": "Lower back" + }, + "alternatingkettlebellrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Alternating+Kettlebell+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Alternating+Kettlebell+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Alternating Kettlebell Row Middle back exercise tutorial", + "category": "Middle back" + }, + "alternatingrenegaderow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Alternating+Renegade+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Alternating+Renegade+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Alternating Renegade Row Middle back exercise tutorial", + "category": "Middle back" + }, + "bentoverbarbellrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bent+Over+Barbell+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bent+Over+Barbell+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bent Over Barbell Row Middle back exercise tutorial", + "category": "Middle back" + }, + "bentoveronearmlongbarrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bent+Over+One-Arm+Long+Bar+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bent+Over+One-Arm+Long+Bar+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bent Over One-Arm Long Bar Row Middle back exercise tutorial", + "category": "Middle back" + }, + "bentovertwoarmlongbarrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bent+Over+Two-Arm+Long+Bar+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bent+Over+Two-Arm+Long+Bar+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bent Over Two-Arm Long Bar Row Middle back exercise tutorial", + "category": "Middle back" + }, + "bentovertwodumbbellrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bent+Over+Two-Dumbbell+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bent+Over+Two-Dumbbell+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bent Over Two-Dumbbell Row Middle back exercise tutorial", + "category": "Middle back" + }, + "bentovertwodumbbellrowwithpalmsin": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bent+Over+Two-Dumbbell+Row+With+Palms+In+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bent+Over+Two-Dumbbell+Row+With+Palms+In+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bent Over Two-Dumbbell Row With Palms In Middle back exercise tutorial", + "category": "Middle back" + }, + "bodyweightmidrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bodyweight+Mid+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bodyweight+Mid+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bodyweight Mid Row Middle back exercise tutorial", + "category": "Middle back" + }, + "dumbbellinclinerow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Incline+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Incline+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Incline Row Middle back exercise tutorial", + "category": "Middle back" + }, + "inclinebenchpull": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Bench+Pull+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Bench+Pull+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Bench Pull Middle back exercise tutorial", + "category": "Middle back" + }, + "invertedrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Inverted+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Inverted+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Inverted Row Middle back exercise tutorial", + "category": "Middle back" + }, + "invertedrowwithstraps": { + "youtubeLink": "https://www.youtube.com/results?search_query=Inverted+Row+with+Straps+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Inverted+Row+with+Straps+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Inverted Row with Straps Middle back exercise tutorial", + "category": "Middle back" + }, + "leveragehighrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Leverage+High+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Leverage+High+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Leverage High Row Middle back exercise tutorial", + "category": "Middle back" + }, + "lyingcamberedbarbellrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Cambered+Barbell+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Cambered+Barbell+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Cambered Barbell Row Middle back exercise tutorial", + "category": "Middle back" + }, + "lyingtbarrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+T-Bar+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+T-Bar+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying T-Bar Row Middle back exercise tutorial", + "category": "Middle back" + }, + "middlebackshrug": { + "youtubeLink": "https://www.youtube.com/results?search_query=Middle+Back+Shrug+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Middle+Back+Shrug+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Middle Back Shrug Middle back exercise tutorial", + "category": "Middle back" + }, + "middlebackstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Middle+Back+Stretch+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Middle+Back+Stretch+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Middle Back Stretch Middle back exercise tutorial", + "category": "Middle back" + }, + "mixedgripchin": { + "youtubeLink": "https://www.youtube.com/results?search_query=Mixed+Grip+Chin+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Mixed+Grip+Chin+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Mixed Grip Chin Middle back exercise tutorial", + "category": "Middle back" + }, + "onearmchinup": { + "youtubeLink": "https://www.youtube.com/results?search_query=One+Arm+Chin-Up+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One+Arm+Chin-Up+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "One Arm Chin-Up Middle back exercise tutorial", + "category": "Middle back" + }, + "onearmdumbbellrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Dumbbell+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Dumbbell+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Dumbbell Row Middle back exercise tutorial", + "category": "Middle back" + }, + "onearmkettlebellrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Kettlebell Row Middle back exercise tutorial", + "category": "Middle back" + }, + "onearmlongbarrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Long+Bar+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Long+Bar+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Long Bar Row Middle back exercise tutorial", + "category": "Middle back" + }, + "reversegripbentoverrows": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Grip+Bent-Over+Rows+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Grip+Bent-Over+Rows+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Grip Bent-Over Rows Middle back exercise tutorial", + "category": "Middle back" + }, + "rhomboidssmr": { + "youtubeLink": "https://www.youtube.com/results?search_query=Rhomboids-SMR+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Rhomboids-SMR+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Rhomboids-SMR Middle back exercise tutorial", + "category": "Middle back" + }, + "seatedcablerows": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Cable+Rows+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Cable+Rows+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Cable Rows Middle back exercise tutorial", + "category": "Middle back" + }, + "seatedonearmcablepulleyrows": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+One-arm+Cable+Pulley+Rows+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+One-arm+Cable+Pulley+Rows+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated One-arm Cable Pulley Rows Middle back exercise tutorial", + "category": "Middle back" + }, + "sledrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Sled+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Sled+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Sled Row Middle back exercise tutorial", + "category": "Middle back" + }, + "smithmachinebentoverrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Bent+Over+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Bent+Over+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Bent Over Row Middle back exercise tutorial", + "category": "Middle back" + }, + "spinalstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Spinal+Stretch+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Spinal+Stretch+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Spinal Stretch Middle back exercise tutorial", + "category": "Middle back" + }, + "straightbarbenchmidrows": { + "youtubeLink": "https://www.youtube.com/results?search_query=Straight+Bar+Bench+Mid+Rows+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Straight+Bar+Bench+Mid+Rows+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Straight Bar Bench Mid Rows Middle back exercise tutorial", + "category": "Middle back" + }, + "suspendedrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Suspended+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Suspended+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Suspended Row Middle back exercise tutorial", + "category": "Middle back" + }, + "tbarrowwithhandle": { + "youtubeLink": "https://www.youtube.com/results?search_query=T-Bar+Row+with+Handle+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=T-Bar+Row+with+Handle+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "T-Bar Row with Handle Middle back exercise tutorial", + "category": "Middle back" + }, + "twoarmkettlebellrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Two-Arm+Kettlebell+Row+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Two-Arm+Kettlebell+Row+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Two-Arm Kettlebell Row Middle back exercise tutorial", + "category": "Middle back" + }, + "upperbackstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Upper+Back+Stretch+Middle+back+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Upper+Back+Stretch+Middle+back+exercise+tutorial+shorts", + "youtubeSearchQuery": "Upper Back Stretch Middle back exercise tutorial", + "category": "Middle back" + }, + "chintocheststretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Chin+To+Chest+Stretch+Neck+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Chin+To+Chest+Stretch+Neck+exercise+tutorial+shorts", + "youtubeSearchQuery": "Chin To Chest Stretch Neck exercise tutorial", + "category": "Neck" + }, + "isometricneckexercisefrontandback": { + "youtubeLink": "https://www.youtube.com/results?search_query=Isometric+Neck+Exercise+-+Front+And+Back+Neck+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Isometric+Neck+Exercise+-+Front+And+Back+Neck+exercise+tutorial+shorts", + "youtubeSearchQuery": "Isometric Neck Exercise - Front And Back Neck exercise tutorial", + "category": "Neck" + }, + "isometricneckexercisesides": { + "youtubeLink": "https://www.youtube.com/results?search_query=Isometric+Neck+Exercise+-+Sides+Neck+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Isometric+Neck+Exercise+-+Sides+Neck+exercise+tutorial+shorts", + "youtubeSearchQuery": "Isometric Neck Exercise - Sides Neck exercise tutorial", + "category": "Neck" + }, + "lyingfacedownplateneckresistance": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Face+Down+Plate+Neck+Resistance+Neck+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Face+Down+Plate+Neck+Resistance+Neck+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Face Down Plate Neck Resistance Neck exercise tutorial", + "category": "Neck" + }, + "lyingfaceupplateneckresistance": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Face+Up+Plate+Neck+Resistance+Neck+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Face+Up+Plate+Neck+Resistance+Neck+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Face Up Plate Neck Resistance Neck exercise tutorial", + "category": "Neck" + }, + "necksmr": { + "youtubeLink": "https://www.youtube.com/results?search_query=Neck-SMR+Neck+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Neck-SMR+Neck+exercise+tutorial+shorts", + "youtubeSearchQuery": "Neck-SMR Neck exercise tutorial", + "category": "Neck" + }, + "seatedheadharnessneckresistance": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Head+Harness+Neck+Resistance+Neck+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Head+Harness+Neck+Resistance+Neck+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Head Harness Neck Resistance Neck exercise tutorial", + "category": "Neck" + }, + "sideneckstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Side+Neck+Stretch+Neck+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Side+Neck+Stretch+Neck+exercise+tutorial+shorts", + "youtubeSearchQuery": "Side Neck Stretch Neck exercise tutorial", + "category": "Neck" + }, + "allfoursquadstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=All+Fours+Quad+Stretch+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=All+Fours+Quad+Stretch+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "All Fours Quad Stretch Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "alternatelegdiagonalbound": { + "youtubeLink": "https://www.youtube.com/results?search_query=Alternate+Leg+Diagonal+Bound+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Alternate+Leg+Diagonal+Bound+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Alternate Leg Diagonal Bound Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "backwarddrag": { + "youtubeLink": "https://www.youtube.com/results?search_query=Backward+Drag+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Backward+Drag+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Backward Drag Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "barbellfullsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Full+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Full+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Full Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "barbellhacksquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Hack+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Hack+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Hack Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "barbelllunge": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Lunge+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Lunge+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Lunge Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "barbellsidesplitsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Side+Split+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Side+Split+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Side Split Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "barbellsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "barbellsquattoabench": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Squat+To+A+Bench+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Squat+To+A+Bench+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Squat To A Bench Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "barbellstepups": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Step+Ups+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Step+Ups+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Step Ups Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "barbellwalkinglunge": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Walking+Lunge+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Walking+Lunge+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Walking Lunge Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "bearcrawlsleddrags": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bear+Crawl+Sled+Drags+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bear+Crawl+Sled+Drags+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bear Crawl Sled Drags Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "benchjump": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bench+Jump+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bench+Jump+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bench Jump Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "benchsprint": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bench+Sprint+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bench+Sprint+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bench Sprint Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "bicycling": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bicycling+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bicycling+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bicycling Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "bicyclingstationary": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bicycling%2C+Stationary+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bicycling%2C+Stationary+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bicycling, Stationary Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "bodyweightsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bodyweight+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bodyweight+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bodyweight Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "bodyweightwalkinglunge": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bodyweight+Walking+Lunge+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bodyweight+Walking+Lunge+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bodyweight Walking Lunge Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "boxsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Box+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Box+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Box Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "boxsquatwithbands": { + "youtubeLink": "https://www.youtube.com/results?search_query=Box+Squat+with+Bands+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Box+Squat+with+Bands+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Box Squat with Bands Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "boxsquatwithchains": { + "youtubeLink": "https://www.youtube.com/results?search_query=Box+Squat+with+Chains+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Box+Squat+with+Chains+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Box Squat with Chains Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "cabledeadlifts": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Deadlifts+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Deadlifts+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Deadlifts Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "cablehipadduction": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Hip+Adduction+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Hip+Adduction+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Hip Adduction Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "cardeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Car+Deadlift+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Car+Deadlift+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Car Deadlift Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "chairsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Chair+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Chair+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Chair Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "cleanpull": { + "youtubeLink": "https://www.youtube.com/results?search_query=Clean+Pull+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Clean+Pull+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Clean Pull Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "cleanfromblocks": { + "youtubeLink": "https://www.youtube.com/results?search_query=Clean+from+Blocks+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Clean+from+Blocks+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Clean from Blocks Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "conanswheel": { + "youtubeLink": "https://www.youtube.com/results?search_query=Conan%27s+Wheel+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Conan%27s+Wheel+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Conan's Wheel Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "depthjumpleap": { + "youtubeLink": "https://www.youtube.com/results?search_query=Depth+Jump+Leap+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Depth+Jump+Leap+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Depth Jump Leap Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "doublelegbuttkick": { + "youtubeLink": "https://www.youtube.com/results?search_query=Double+Leg+Butt+Kick+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Double+Leg+Butt+Kick+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Double Leg Butt Kick Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "dumbbelllunges": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Lunges+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Lunges+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Lunges Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "dumbbellrearlunge": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Rear+Lunge+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Rear+Lunge+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Rear Lunge Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "dumbbellseatedboxjump": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Seated+Box+Jump+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Seated+Box+Jump+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Seated Box Jump Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "dumbbellsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "dumbbellsquattoabench": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Squat+To+A+Bench+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Squat+To+A+Bench+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Squat To A Bench Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "dumbbellstepups": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Step+Ups+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Step+Ups+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Step Ups Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "elevatedbacklunge": { + "youtubeLink": "https://www.youtube.com/results?search_query=Elevated+Back+Lunge+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Elevated+Back+Lunge+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Elevated Back Lunge Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "ellipticaltrainer": { + "youtubeLink": "https://www.youtube.com/results?search_query=Elliptical+Trainer+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Elliptical+Trainer+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Elliptical Trainer Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "fastskipping": { + "youtubeLink": "https://www.youtube.com/results?search_query=Fast+Skipping+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Fast+Skipping+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Fast Skipping Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "frankensteinsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Frankenstein+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Frankenstein+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Frankenstein Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "freehandjumpsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Freehand+Jump+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Freehand+Jump+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Freehand Jump Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "froghops": { + "youtubeLink": "https://www.youtube.com/results?search_query=Frog+Hops+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Frog+Hops+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Frog Hops Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "frontbarbellsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Front+Barbell+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Front+Barbell+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Front Barbell Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "frontbarbellsquattoabench": { + "youtubeLink": "https://www.youtube.com/results?search_query=Front+Barbell+Squat+To+A+Bench+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Front+Barbell+Squat+To+A+Bench+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Front Barbell Squat To A Bench Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "frontconehopsorhurdlehops": { + "youtubeLink": "https://www.youtube.com/results?search_query=Front+Cone+Hops+%28or+hurdle+hops%29+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Front+Cone+Hops+%28or+hurdle+hops%29+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Front Cone Hops (or hurdle hops) Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "frontsquatcleangrip": { + "youtubeLink": "https://www.youtube.com/results?search_query=Front+Squat+%28Clean+Grip%29+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Front+Squat+%28Clean+Grip%29+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Front Squat (Clean Grip) Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "frontsquatswithtwokettlebells": { + "youtubeLink": "https://www.youtube.com/results?search_query=Front+Squats+With+Two+Kettlebells+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Front+Squats+With+Two+Kettlebells+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Front Squats With Two Kettlebells Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "gobletsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Goblet+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Goblet+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Goblet Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "hacksquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hack+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hack+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hack Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "hangclean": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hang+Clean+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hang+Clean+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hang Clean Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "hangcleanbelowtheknees": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hang+Clean+-+Below+the+Knees+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hang+Clean+-+Below+the+Knees+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hang Clean - Below the Knees Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "heavingsnatchbalance": { + "youtubeLink": "https://www.youtube.com/results?search_query=Heaving+Snatch+Balance+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Heaving+Snatch+Balance+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Heaving Snatch Balance Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "hipflexionwithband": { + "youtubeLink": "https://www.youtube.com/results?search_query=Hip+Flexion+with+Band+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Hip+Flexion+with+Band+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Hip Flexion with Band Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "intermediatehipflexorandquadstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Intermediate+Hip+Flexor+and+Quad+Stretch+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Intermediate+Hip+Flexor+and+Quad+Stretch+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Intermediate Hip Flexor and Quad Stretch Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "ironcrossesstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Iron+Crosses+%28stretch%29+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Iron+Crosses+%28stretch%29+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Iron Crosses (stretch) Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "jeffersonsquats": { + "youtubeLink": "https://www.youtube.com/results?search_query=Jefferson+Squats+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Jefferson+Squats+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Jefferson Squats Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "jerkdipsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Jerk+Dip+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Jerk+Dip+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Jerk Dip Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "joggingtreadmill": { + "youtubeLink": "https://www.youtube.com/results?search_query=Jogging%2C+Treadmill+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Jogging%2C+Treadmill+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Jogging, Treadmill Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "kettlebellpistolsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kettlebell+Pistol+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kettlebell+Pistol+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kettlebell Pistol Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "kneelinghipflexor": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kneeling+Hip+Flexor+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kneeling+Hip+Flexor+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kneeling Hip Flexor Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "legextensions": { + "youtubeLink": "https://www.youtube.com/results?search_query=Leg+Extensions+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Leg+Extensions+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Leg Extensions Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "legpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Leg+Press+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Leg+Press+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Leg Press Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "leveragedeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Leverage+Deadlift+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Leverage+Deadlift+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Leverage Deadlift Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "lineardepthjump": { + "youtubeLink": "https://www.youtube.com/results?search_query=Linear+Depth+Jump+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Linear+Depth+Jump+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Linear Depth Jump Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "lookingatceiling": { + "youtubeLink": "https://www.youtube.com/results?search_query=Looking+At+Ceiling+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Looking+At+Ceiling+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Looking At Ceiling Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "lungesprint": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lunge+Sprint+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lunge+Sprint+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lunge Sprint Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "lyingmachinesquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Machine+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Machine+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Machine Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "lyingpronequadriceps": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Prone+Quadriceps+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Prone+Quadriceps+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Prone Quadriceps Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "mountainclimbers": { + "youtubeLink": "https://www.youtube.com/results?search_query=Mountain+Climbers+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Mountain+Climbers+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Mountain Climbers Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "narrowstancehacksquats": { + "youtubeLink": "https://www.youtube.com/results?search_query=Narrow+Stance+Hack+Squats+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Narrow+Stance+Hack+Squats+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Narrow Stance Hack Squats Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "narrowstancelegpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Narrow+Stance+Leg+Press+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Narrow+Stance+Leg+Press+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Narrow Stance Leg Press Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "narrowstancesquats": { + "youtubeLink": "https://www.youtube.com/results?search_query=Narrow+Stance+Squats+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Narrow+Stance+Squats+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Narrow Stance Squats Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "olympicsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Olympic+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Olympic+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Olympic Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "onyoursidequadstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=On+Your+Side+Quad+Stretch+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=On+Your+Side+Quad+Stretch+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "On Your Side Quad Stretch Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "onyourbackquadstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=On-Your-Back+Quad+Stretch+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=On-Your-Back+Quad+Stretch+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "On-Your-Back Quad Stretch Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "onehalflocust": { + "youtubeLink": "https://www.youtube.com/results?search_query=One+Half+Locust+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One+Half+Locust+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "One Half Locust Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "onelegbarbellsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=One+Leg+Barbell+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One+Leg+Barbell+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "One Leg Barbell Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "onearmoverheadkettlebellsquats": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Overhead+Kettlebell+Squats+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Overhead+Kettlebell+Squats+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Overhead Kettlebell Squats Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "onearmsidedeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Side+Deadlift+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Side+Deadlift+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Side Deadlift Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "overheadsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Overhead+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Overhead+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Overhead Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "pliedumbbellsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Plie+Dumbbell+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Plie+Dumbbell+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Plie Dumbbell Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "powerjerk": { + "youtubeLink": "https://www.youtube.com/results?search_query=Power+Jerk+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Power+Jerk+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Power Jerk Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "powersnatchfromblocks": { + "youtubeLink": "https://www.youtube.com/results?search_query=Power+Snatch+from+Blocks+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Power+Snatch+from+Blocks+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Power Snatch from Blocks Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "quadstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Quad+Stretch+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Quad+Stretch+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Quad Stretch Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "quadricepssmr": { + "youtubeLink": "https://www.youtube.com/results?search_query=Quadriceps-SMR+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Quadriceps-SMR+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Quadriceps-SMR Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "quickleap": { + "youtubeLink": "https://www.youtube.com/results?search_query=Quick+Leap+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Quick+Leap+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Quick Leap Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "rearlegraises": { + "youtubeLink": "https://www.youtube.com/results?search_query=Rear+Leg+Raises+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Rear+Leg+Raises+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Rear Leg Raises Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "recumbentbike": { + "youtubeLink": "https://www.youtube.com/results?search_query=Recumbent+Bike+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Recumbent+Bike+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Recumbent Bike Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "reversebandboxsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Band+Box+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Band+Box+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Band Box Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "reversebandpowersquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Band+Power+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Band+Power+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Band Power Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "rickshawdeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Rickshaw+Deadlift+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Rickshaw+Deadlift+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Rickshaw Deadlift Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "rocketjump": { + "youtubeLink": "https://www.youtube.com/results?search_query=Rocket+Jump+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Rocket+Jump+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Rocket Jump Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "ropejumping": { + "youtubeLink": "https://www.youtube.com/results?search_query=Rope+Jumping+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Rope+Jumping+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Rope Jumping Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "rowingstationary": { + "youtubeLink": "https://www.youtube.com/results?search_query=Rowing%2C+Stationary+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Rowing%2C+Stationary+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Rowing, Stationary Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "runningtreadmill": { + "youtubeLink": "https://www.youtube.com/results?search_query=Running%2C+Treadmill+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Running%2C+Treadmill+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Running, Treadmill Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "sandbagload": { + "youtubeLink": "https://www.youtube.com/results?search_query=Sandbag+Load+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Sandbag+Load+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Sandbag Load Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "scissorsjump": { + "youtubeLink": "https://www.youtube.com/results?search_query=Scissors+Jump+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Scissors+Jump+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Scissors Jump Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "sidehopsprint": { + "youtubeLink": "https://www.youtube.com/results?search_query=Side+Hop-Sprint+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Side+Hop-Sprint+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Side Hop-Sprint Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "sidestandinglongjump": { + "youtubeLink": "https://www.youtube.com/results?search_query=Side+Standing+Long+Jump+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Side+Standing+Long+Jump+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Side Standing Long Jump Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "sidetosideboxshuffle": { + "youtubeLink": "https://www.youtube.com/results?search_query=Side+to+Side+Box+Shuffle+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Side+to+Side+Box+Shuffle+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Side to Side Box Shuffle Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "singlelegbuttkick": { + "youtubeLink": "https://www.youtube.com/results?search_query=Single+Leg+Butt+Kick+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Single+Leg+Butt+Kick+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Single Leg Butt Kick Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "singlelegpushoff": { + "youtubeLink": "https://www.youtube.com/results?search_query=Single+Leg+Push-off+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Single+Leg+Push-off+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Single Leg Push-off Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "singleconesprintdrill": { + "youtubeLink": "https://www.youtube.com/results?search_query=Single-Cone+Sprint+Drill+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Single-Cone+Sprint+Drill+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Single-Cone Sprint Drill Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "singleleghighboxsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Single-Leg+High+Box+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Single-Leg+High+Box+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Single-Leg High Box Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "singleleghopprogression": { + "youtubeLink": "https://www.youtube.com/results?search_query=Single-Leg+Hop+Progression+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Single-Leg+Hop+Progression+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Single-Leg Hop Progression Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "singleleglateralhop": { + "youtubeLink": "https://www.youtube.com/results?search_query=Single-Leg+Lateral+Hop+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Single-Leg+Lateral+Hop+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Single-Leg Lateral Hop Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "singleleglegextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Single-Leg+Leg+Extension+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Single-Leg+Leg+Extension+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Single-Leg Leg Extension Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "singlelegstridejump": { + "youtubeLink": "https://www.youtube.com/results?search_query=Single-Leg+Stride+Jump+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Single-Leg+Stride+Jump+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Single-Leg Stride Jump Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "sitsquats": { + "youtubeLink": "https://www.youtube.com/results?search_query=Sit+Squats+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Sit+Squats+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Sit Squats Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "skating": { + "youtubeLink": "https://www.youtube.com/results?search_query=Skating+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Skating+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Skating Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "sleddragharness": { + "youtubeLink": "https://www.youtube.com/results?search_query=Sled+Drag+-+Harness+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Sled+Drag+-+Harness+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Sled Drag - Harness Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "sledpush": { + "youtubeLink": "https://www.youtube.com/results?search_query=Sled+Push+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Sled+Push+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Sled Push Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "smithmachinelegpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Leg+Press+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Leg+Press+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Leg Press Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "smithmachinepistolsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Pistol+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Pistol+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Pistol Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "smithmachinesquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "smithsinglelegsplitsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Single-Leg+Split+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Single-Leg+Split+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Single-Leg Split Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "snatch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Snatch+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Snatch+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Snatch Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "snatchbalance": { + "youtubeLink": "https://www.youtube.com/results?search_query=Snatch+Balance+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Snatch+Balance+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Snatch Balance Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "snatchfromblocks": { + "youtubeLink": "https://www.youtube.com/results?search_query=Snatch+from+Blocks+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Snatch+from+Blocks+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Snatch from Blocks Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "speedboxsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Speed+Box+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Speed+Box+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Speed Box Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "speedsquats": { + "youtubeLink": "https://www.youtube.com/results?search_query=Speed+Squats+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Speed+Squats+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Speed Squats Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "splitclean": { + "youtubeLink": "https://www.youtube.com/results?search_query=Split+Clean+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Split+Clean+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Split Clean Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "splitjerk": { + "youtubeLink": "https://www.youtube.com/results?search_query=Split+Jerk+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Split+Jerk+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Split Jerk Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "splitjump": { + "youtubeLink": "https://www.youtube.com/results?search_query=Split+Jump+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Split+Jump+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Split Jump Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "splitsquatwithdumbbells": { + "youtubeLink": "https://www.youtube.com/results?search_query=Split+Squat+with+Dumbbells+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Split+Squat+with+Dumbbells+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Split Squat with Dumbbells Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "squatjerk": { + "youtubeLink": "https://www.youtube.com/results?search_query=Squat+Jerk+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Squat+Jerk+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Squat Jerk Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "squatwithbands": { + "youtubeLink": "https://www.youtube.com/results?search_query=Squat+with+Bands+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Squat+with+Bands+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Squat with Bands Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "squatwithchains": { + "youtubeLink": "https://www.youtube.com/results?search_query=Squat+with+Chains+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Squat+with+Chains+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Squat with Chains Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "squatwithplatemovers": { + "youtubeLink": "https://www.youtube.com/results?search_query=Squat+with+Plate+Movers+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Squat+with+Plate+Movers+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Squat with Plate Movers Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "squatswithbands": { + "youtubeLink": "https://www.youtube.com/results?search_query=Squats+-+With+Bands+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Squats+-+With+Bands+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Squats - With Bands Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "stairmaster": { + "youtubeLink": "https://www.youtube.com/results?search_query=Stairmaster+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Stairmaster+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Stairmaster Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "standingelevatedquadstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Elevated+Quad+Stretch+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Elevated+Quad+Stretch+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Elevated Quad Stretch Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "standinghipflexors": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Hip+Flexors+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Hip+Flexors+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Hip Flexors Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "standinglongjump": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Long+Jump+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Long+Jump+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Long Jump Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "starjump": { + "youtubeLink": "https://www.youtube.com/results?search_query=Star+Jump+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Star+Jump+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Star Jump Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "stepmill": { + "youtubeLink": "https://www.youtube.com/results?search_query=Step+Mill+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Step+Mill+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Step Mill Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "stridejumpcrossover": { + "youtubeLink": "https://www.youtube.com/results?search_query=Stride+Jump+Crossover+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Stride+Jump+Crossover+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Stride Jump Crossover Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "suspendedsplitsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Suspended+Split+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Suspended+Split+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Suspended Split Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "tireflip": { + "youtubeLink": "https://www.youtube.com/results?search_query=Tire+Flip+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Tire+Flip+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Tire Flip Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "trailrunningwalking": { + "youtubeLink": "https://www.youtube.com/results?search_query=Trail+Running%2FWalking+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Trail+Running%2FWalking+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Trail Running/Walking Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "trapbardeadlift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Trap+Bar+Deadlift+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Trap+Bar+Deadlift+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Trap Bar Deadlift Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "walkingtreadmill": { + "youtubeLink": "https://www.youtube.com/results?search_query=Walking%2C+Treadmill+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Walking%2C+Treadmill+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Walking, Treadmill Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "weightedjumpsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Weighted+Jump+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Weighted+Jump+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Weighted Jump Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "weightedsissysquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Weighted+Sissy+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Weighted+Sissy+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Weighted Sissy Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "weightedsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Weighted+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Weighted+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Weighted Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "widestancebarbellsquat": { + "youtubeLink": "https://www.youtube.com/results?search_query=Wide+Stance+Barbell+Squat+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Wide+Stance+Barbell+Squat+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Wide Stance Barbell Squat Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "yokewalk": { + "youtubeLink": "https://www.youtube.com/results?search_query=Yoke+Walk+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Yoke+Walk+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Yoke Walk Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "zerchersquats": { + "youtubeLink": "https://www.youtube.com/results?search_query=Zercher+Squats+Quadriceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Zercher+Squats+Quadriceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Zercher Squats Quadriceps exercise tutorial", + "category": "Quadriceps" + }, + "alternatingcableshoulderpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Alternating+Cable+Shoulder+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Alternating+Cable+Shoulder+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Alternating Cable Shoulder Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "alternatingdeltoidraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Alternating+Deltoid+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Alternating+Deltoid+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Alternating Deltoid Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "alternatingkettlebellpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Alternating+Kettlebell+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Alternating+Kettlebell+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Alternating Kettlebell Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "antigravitypress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Anti-Gravity+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Anti-Gravity+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Anti-Gravity Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "armcircles": { + "youtubeLink": "https://www.youtube.com/results?search_query=Arm+Circles+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Arm+Circles+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Arm Circles Shoulders exercise tutorial", + "category": "Shoulders" + }, + "arnolddumbbellpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Arnold+Dumbbell+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Arnold+Dumbbell+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Arnold Dumbbell Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "backflyeswithbands": { + "youtubeLink": "https://www.youtube.com/results?search_query=Back+Flyes+-+With+Bands+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Back+Flyes+-+With+Bands+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Back Flyes - With Bands Shoulders exercise tutorial", + "category": "Shoulders" + }, + "backwardmedicineballthrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Backward+Medicine+Ball+Throw+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Backward+Medicine+Ball+Throw+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Backward Medicine Ball Throw Shoulders exercise tutorial", + "category": "Shoulders" + }, + "bandpullapart": { + "youtubeLink": "https://www.youtube.com/results?search_query=Band+Pull+Apart+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Band+Pull+Apart+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Band Pull Apart Shoulders exercise tutorial", + "category": "Shoulders" + }, + "barbellinclineshoulderraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Incline+Shoulder+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Incline+Shoulder+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Incline Shoulder Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "barbellreardeltrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Rear+Delt+Row+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Rear+Delt+Row+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Rear Delt Row Shoulders exercise tutorial", + "category": "Shoulders" + }, + "barbellshoulderpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Shoulder+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Shoulder+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Shoulder Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "battlingropes": { + "youtubeLink": "https://www.youtube.com/results?search_query=Battling+Ropes+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Battling+Ropes+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Battling Ropes Shoulders exercise tutorial", + "category": "Shoulders" + }, + "bentoverdumbbellreardeltraisewithheadonbench": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bent+Over+Dumbbell+Rear+Delt+Raise+With+Head+On+Bench+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bent+Over+Dumbbell+Rear+Delt+Raise+With+Head+On+Bench+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bent Over Dumbbell Rear Delt Raise With Head On Bench Shoulders exercise tutorial", + "category": "Shoulders" + }, + "bentoverlowpulleysidelateral": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bent+Over+Low-Pulley+Side+Lateral+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bent+Over+Low-Pulley+Side+Lateral+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bent Over Low-Pulley Side Lateral Shoulders exercise tutorial", + "category": "Shoulders" + }, + "bradfordrockypresses": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bradford%2FRocky+Presses+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bradford%2FRocky+Presses+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bradford/Rocky Presses Shoulders exercise tutorial", + "category": "Shoulders" + }, + "cableinternalrotation": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Internal+Rotation+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Internal+Rotation+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Internal Rotation Shoulders exercise tutorial", + "category": "Shoulders" + }, + "cablereardeltfly": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Rear+Delt+Fly+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Rear+Delt+Fly+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Rear Delt Fly Shoulders exercise tutorial", + "category": "Shoulders" + }, + "cableropereardeltrows": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Rope+Rear-Delt+Rows+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Rope+Rear-Delt+Rows+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Rope Rear-Delt Rows Shoulders exercise tutorial", + "category": "Shoulders" + }, + "cableseatedlateralraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Seated+Lateral+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Seated+Lateral+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Seated Lateral Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "cableshoulderpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Shoulder+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Shoulder+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Shoulder Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "cardrivers": { + "youtubeLink": "https://www.youtube.com/results?search_query=Car+Drivers+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Car+Drivers+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Car Drivers Shoulders exercise tutorial", + "category": "Shoulders" + }, + "chairupperbodystretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Chair+Upper+Body+Stretch+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Chair+Upper+Body+Stretch+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Chair Upper Body Stretch Shoulders exercise tutorial", + "category": "Shoulders" + }, + "circusbell": { + "youtubeLink": "https://www.youtube.com/results?search_query=Circus+Bell+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Circus+Bell+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Circus Bell Shoulders exercise tutorial", + "category": "Shoulders" + }, + "cleanandjerk": { + "youtubeLink": "https://www.youtube.com/results?search_query=Clean+and+Jerk+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Clean+and+Jerk+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Clean and Jerk Shoulders exercise tutorial", + "category": "Shoulders" + }, + "cleanandpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Clean+and+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Clean+and+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Clean and Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "crucifix": { + "youtubeLink": "https://www.youtube.com/results?search_query=Crucifix+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Crucifix+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Crucifix Shoulders exercise tutorial", + "category": "Shoulders" + }, + "cubanpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cuban+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cuban+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cuban Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "doublekettlebelljerk": { + "youtubeLink": "https://www.youtube.com/results?search_query=Double+Kettlebell+Jerk+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Double+Kettlebell+Jerk+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Double Kettlebell Jerk Shoulders exercise tutorial", + "category": "Shoulders" + }, + "doublekettlebellpushpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Double+Kettlebell+Push+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Double+Kettlebell+Push+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Double Kettlebell Push Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "doublekettlebellsnatch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Double+Kettlebell+Snatch+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Double+Kettlebell+Snatch+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Double Kettlebell Snatch Shoulders exercise tutorial", + "category": "Shoulders" + }, + "dumbbellinclineshoulderraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Incline+Shoulder+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Incline+Shoulder+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Incline Shoulder Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "dumbbelllyingonearmrearlateralraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Lying+One-Arm+Rear+Lateral+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Lying+One-Arm+Rear+Lateral+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Lying One-Arm Rear Lateral Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "dumbbelllyingrearlateralraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Lying+Rear+Lateral+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Lying+Rear+Lateral+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Lying Rear Lateral Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "dumbbellonearmshoulderpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+One-Arm+Shoulder+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+One-Arm+Shoulder+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell One-Arm Shoulder Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "dumbbellonearmuprightrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+One-Arm+Upright+Row+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+One-Arm+Upright+Row+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell One-Arm Upright Row Shoulders exercise tutorial", + "category": "Shoulders" + }, + "dumbbellraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "dumbbellscaption": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Scaption+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Scaption+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Scaption Shoulders exercise tutorial", + "category": "Shoulders" + }, + "dumbbellshoulderpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Shoulder+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Shoulder+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Shoulder Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "elbowcircles": { + "youtubeLink": "https://www.youtube.com/results?search_query=Elbow+Circles+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Elbow+Circles+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Elbow Circles Shoulders exercise tutorial", + "category": "Shoulders" + }, + "externalrotation": { + "youtubeLink": "https://www.youtube.com/results?search_query=External+Rotation+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=External+Rotation+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "External Rotation Shoulders exercise tutorial", + "category": "Shoulders" + }, + "externalrotationwithband": { + "youtubeLink": "https://www.youtube.com/results?search_query=External+Rotation+with+Band+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=External+Rotation+with+Band+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "External Rotation with Band Shoulders exercise tutorial", + "category": "Shoulders" + }, + "externalrotationwithcable": { + "youtubeLink": "https://www.youtube.com/results?search_query=External+Rotation+with+Cable+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=External+Rotation+with+Cable+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "External Rotation with Cable Shoulders exercise tutorial", + "category": "Shoulders" + }, + "facepull": { + "youtubeLink": "https://www.youtube.com/results?search_query=Face+Pull+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Face+Pull+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Face Pull Shoulders exercise tutorial", + "category": "Shoulders" + }, + "frontcableraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Front+Cable+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Front+Cable+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Front Cable Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "frontdumbbellraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Front+Dumbbell+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Front+Dumbbell+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Front Dumbbell Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "frontinclinedumbbellraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Front+Incline+Dumbbell+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Front+Incline+Dumbbell+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Front Incline Dumbbell Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "frontplateraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Front+Plate+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Front+Plate+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Front Plate Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "fronttwodumbbellraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Front+Two-Dumbbell+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Front+Two-Dumbbell+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Front Two-Dumbbell Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "handstandpushups": { + "youtubeLink": "https://www.youtube.com/results?search_query=Handstand+Push-Ups+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Handstand+Push-Ups+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Handstand Push-Ups Shoulders exercise tutorial", + "category": "Shoulders" + }, + "internalrotationwithband": { + "youtubeLink": "https://www.youtube.com/results?search_query=Internal+Rotation+with+Band+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Internal+Rotation+with+Band+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Internal Rotation with Band Shoulders exercise tutorial", + "category": "Shoulders" + }, + "ironcross": { + "youtubeLink": "https://www.youtube.com/results?search_query=Iron+Cross+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Iron+Cross+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Iron Cross Shoulders exercise tutorial", + "category": "Shoulders" + }, + "jerkbalance": { + "youtubeLink": "https://www.youtube.com/results?search_query=Jerk+Balance+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Jerk+Balance+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Jerk Balance Shoulders exercise tutorial", + "category": "Shoulders" + }, + "kettlebellarnoldpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kettlebell+Arnold+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kettlebell+Arnold+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kettlebell Arnold Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "kettlebellpirateships": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kettlebell+Pirate+Ships+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kettlebell+Pirate+Ships+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kettlebell Pirate Ships Shoulders exercise tutorial", + "category": "Shoulders" + }, + "kettlebellseatedpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kettlebell+Seated+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kettlebell+Seated+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kettlebell Seated Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "kettlebellseesawpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kettlebell+Seesaw+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kettlebell+Seesaw+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kettlebell Seesaw Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "kettlebellthruster": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kettlebell+Thruster+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kettlebell+Thruster+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kettlebell Thruster Shoulders exercise tutorial", + "category": "Shoulders" + }, + "kettlebellturkishgetuplungestyle": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kettlebell+Turkish+Get-Up+%28Lunge+style%29+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kettlebell+Turkish+Get-Up+%28Lunge+style%29+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kettlebell Turkish Get-Up (Lunge style) Shoulders exercise tutorial", + "category": "Shoulders" + }, + "kettlebellturkishgetupsquatstyle": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kettlebell+Turkish+Get-Up+%28Squat+style%29+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kettlebell+Turkish+Get-Up+%28Squat+style%29+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kettlebell Turkish Get-Up (Squat style) Shoulders exercise tutorial", + "category": "Shoulders" + }, + "kneelingarmdrill": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kneeling+Arm+Drill+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kneeling+Arm+Drill+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kneeling Arm Drill Shoulders exercise tutorial", + "category": "Shoulders" + }, + "landminelinearjammer": { + "youtubeLink": "https://www.youtube.com/results?search_query=Landmine+Linear+Jammer+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Landmine+Linear+Jammer+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Landmine Linear Jammer Shoulders exercise tutorial", + "category": "Shoulders" + }, + "lateralraisewithbands": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lateral+Raise+-+With+Bands+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lateral+Raise+-+With+Bands+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lateral Raise - With Bands Shoulders exercise tutorial", + "category": "Shoulders" + }, + "leverageshoulderpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Leverage+Shoulder+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Leverage+Shoulder+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Leverage Shoulder Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "loglift": { + "youtubeLink": "https://www.youtube.com/results?search_query=Log+Lift+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Log+Lift+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Log Lift Shoulders exercise tutorial", + "category": "Shoulders" + }, + "lowpulleyrowtoneck": { + "youtubeLink": "https://www.youtube.com/results?search_query=Low+Pulley+Row+To+Neck+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Low+Pulley+Row+To+Neck+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Low Pulley Row To Neck Shoulders exercise tutorial", + "category": "Shoulders" + }, + "lyingonearmlateralraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+One-Arm+Lateral+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+One-Arm+Lateral+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying One-Arm Lateral Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "lyingreardeltraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Rear+Delt+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Rear+Delt+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Rear Delt Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "machineshouldermilitarypress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Machine+Shoulder+%28Military%29+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Machine+Shoulder+%28Military%29+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Machine Shoulder (Military) Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "medicineballscoopthrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Medicine+Ball+Scoop+Throw+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Medicine+Ball+Scoop+Throw+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Medicine Ball Scoop Throw Shoulders exercise tutorial", + "category": "Shoulders" + }, + "onearminclinelateralraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Incline+Lateral+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Incline+Lateral+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Incline Lateral Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "onearmkettlebellcleanandjerk": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Clean+and+Jerk+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Clean+and+Jerk+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Kettlebell Clean and Jerk Shoulders exercise tutorial", + "category": "Shoulders" + }, + "onearmkettlebelljerk": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Jerk+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Jerk+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Kettlebell Jerk Shoulders exercise tutorial", + "category": "Shoulders" + }, + "onearmkettlebellmilitarypresstotheside": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Military+Press+To+The+Side+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Military+Press+To+The+Side+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Kettlebell Military Press To The Side Shoulders exercise tutorial", + "category": "Shoulders" + }, + "onearmkettlebellparapress": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Para+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Para+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Kettlebell Para Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "onearmkettlebellpushpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Push+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Push+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Kettlebell Push Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "onearmkettlebellsnatch": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Snatch+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Snatch+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Kettlebell Snatch Shoulders exercise tutorial", + "category": "Shoulders" + }, + "onearmkettlebellsplitjerk": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Split+Jerk+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Split+Jerk+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Kettlebell Split Jerk Shoulders exercise tutorial", + "category": "Shoulders" + }, + "onearmkettlebellsplitsnatch": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Split+Snatch+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Kettlebell+Split+Snatch+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Kettlebell Split Snatch Shoulders exercise tutorial", + "category": "Shoulders" + }, + "onearmsidelaterals": { + "youtubeLink": "https://www.youtube.com/results?search_query=One-Arm+Side+Laterals+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One-Arm+Side+Laterals+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "One-Arm Side Laterals Shoulders exercise tutorial", + "category": "Shoulders" + }, + "powerpartials": { + "youtubeLink": "https://www.youtube.com/results?search_query=Power+Partials+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Power+Partials+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Power Partials Shoulders exercise tutorial", + "category": "Shoulders" + }, + "pushpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Push+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Push+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Push Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "pushpressbehindtheneck": { + "youtubeLink": "https://www.youtube.com/results?search_query=Push+Press+-+Behind+the+Neck+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Push+Press+-+Behind+the+Neck+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Push Press - Behind the Neck Shoulders exercise tutorial", + "category": "Shoulders" + }, + "rackdelivery": { + "youtubeLink": "https://www.youtube.com/results?search_query=Rack+Delivery+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Rack+Delivery+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Rack Delivery Shoulders exercise tutorial", + "category": "Shoulders" + }, + "returnpushfromstance": { + "youtubeLink": "https://www.youtube.com/results?search_query=Return+Push+from+Stance+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Return+Push+from+Stance+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Return Push from Stance Shoulders exercise tutorial", + "category": "Shoulders" + }, + "reverseflyes": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Flyes+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Flyes+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Flyes Shoulders exercise tutorial", + "category": "Shoulders" + }, + "reverseflyeswithexternalrotation": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Flyes+With+External+Rotation+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Flyes+With+External+Rotation+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Flyes With External Rotation Shoulders exercise tutorial", + "category": "Shoulders" + }, + "reversemachineflyes": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Machine+Flyes+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Machine+Flyes+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Machine Flyes Shoulders exercise tutorial", + "category": "Shoulders" + }, + "roundtheworldshoulderstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Round+The+World+Shoulder+Stretch+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Round+The+World+Shoulder+Stretch+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Round The World Shoulder Stretch Shoulders exercise tutorial", + "category": "Shoulders" + }, + "seatedbarbellmilitarypress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Barbell+Military+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Barbell+Military+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Barbell Military Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "seatedbentoverreardeltraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Bent-Over+Rear+Delt+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Bent-Over+Rear+Delt+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Bent-Over Rear Delt Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "seatedcableshoulderpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Cable+Shoulder+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Cable+Shoulder+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Cable Shoulder Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "seateddumbbellpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Dumbbell+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Dumbbell+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Dumbbell Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "seatedfrontdeltoid": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Front+Deltoid+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Front+Deltoid+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Front Deltoid Shoulders exercise tutorial", + "category": "Shoulders" + }, + "seatedsidelateralraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Side+Lateral+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Side+Lateral+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Side Lateral Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "seesawpressalternatingsidepress": { + "youtubeLink": "https://www.youtube.com/results?search_query=See-Saw+Press+%28Alternating+Side+Press%29+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=See-Saw+Press+%28Alternating+Side+Press%29+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "See-Saw Press (Alternating Side Press) Shoulders exercise tutorial", + "category": "Shoulders" + }, + "shouldercircles": { + "youtubeLink": "https://www.youtube.com/results?search_query=Shoulder+Circles+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Shoulder+Circles+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Shoulder Circles Shoulders exercise tutorial", + "category": "Shoulders" + }, + "shoulderpresswithbands": { + "youtubeLink": "https://www.youtube.com/results?search_query=Shoulder+Press+-+With+Bands+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Shoulder+Press+-+With+Bands+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Shoulder Press - With Bands Shoulders exercise tutorial", + "category": "Shoulders" + }, + "shoulderraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Shoulder+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Shoulder+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Shoulder Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "shoulderstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Shoulder+Stretch+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Shoulder+Stretch+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Shoulder Stretch Shoulders exercise tutorial", + "category": "Shoulders" + }, + "sidelateralraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Side+Lateral+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Side+Lateral+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Side Lateral Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "sidelateralstofrontraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Side+Laterals+to+Front+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Side+Laterals+to+Front+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Side Laterals to Front Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "sidewristpull": { + "youtubeLink": "https://www.youtube.com/results?search_query=Side+Wrist+Pull+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Side+Wrist+Pull+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Side Wrist Pull Shoulders exercise tutorial", + "category": "Shoulders" + }, + "singledumbbellraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Single+Dumbbell+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Single+Dumbbell+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Single Dumbbell Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "singlearmlinearjammer": { + "youtubeLink": "https://www.youtube.com/results?search_query=Single-Arm+Linear+Jammer+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Single-Arm+Linear+Jammer+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Single-Arm Linear Jammer Shoulders exercise tutorial", + "category": "Shoulders" + }, + "sledoverheadbackwardwalk": { + "youtubeLink": "https://www.youtube.com/results?search_query=Sled+Overhead+Backward+Walk+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Sled+Overhead+Backward+Walk+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Sled Overhead Backward Walk Shoulders exercise tutorial", + "category": "Shoulders" + }, + "sledreverseflye": { + "youtubeLink": "https://www.youtube.com/results?search_query=Sled+Reverse+Flye+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Sled+Reverse+Flye+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Sled Reverse Flye Shoulders exercise tutorial", + "category": "Shoulders" + }, + "smithinclineshoulderraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Incline+Shoulder+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Incline+Shoulder+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Incline Shoulder Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "smithmachineonearmuprightrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+One-Arm+Upright+Row+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+One-Arm+Upright+Row+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine One-Arm Upright Row Shoulders exercise tutorial", + "category": "Shoulders" + }, + "smithmachineoverheadshoulderpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Overhead+Shoulder+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Overhead+Shoulder+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Overhead Shoulder Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "standingalternatingdumbbellpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Alternating+Dumbbell+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Alternating+Dumbbell+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Alternating Dumbbell Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "standingbarbellpressbehindneck": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Barbell+Press+Behind+Neck+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Barbell+Press+Behind+Neck+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Barbell Press Behind Neck Shoulders exercise tutorial", + "category": "Shoulders" + }, + "standingbradfordpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Bradford+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Bradford+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Bradford Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "standingdumbbellpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Dumbbell+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Dumbbell+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Dumbbell Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "standingdumbbellstraightarmfrontdeltraiseabovehead": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Dumbbell+Straight-Arm+Front+Delt+Raise+Above+Head+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Dumbbell+Straight-Arm+Front+Delt+Raise+Above+Head+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Dumbbell Straight-Arm Front Delt Raise Above Head Shoulders exercise tutorial", + "category": "Shoulders" + }, + "standingfrontbarbellraiseoverhead": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Front+Barbell+Raise+Over+Head+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Front+Barbell+Raise+Over+Head+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Front Barbell Raise Over Head Shoulders exercise tutorial", + "category": "Shoulders" + }, + "standinglowpulleydeltoidraise": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Low-Pulley+Deltoid+Raise+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Low-Pulley+Deltoid+Raise+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Low-Pulley Deltoid Raise Shoulders exercise tutorial", + "category": "Shoulders" + }, + "standingmilitarypress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Military+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Military+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Military Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "standingpalminonearmdumbbellpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Palm-In+One-Arm+Dumbbell+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Palm-In+One-Arm+Dumbbell+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Palm-In One-Arm Dumbbell Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "standingpalmsindumbbellpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Palms-In+Dumbbell+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Palms-In+Dumbbell+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Palms-In Dumbbell Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "standingtwoarmoverheadthrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Two-Arm+Overhead+Throw+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Two-Arm+Overhead+Throw+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Two-Arm Overhead Throw Shoulders exercise tutorial", + "category": "Shoulders" + }, + "straightraisesoninclinebench": { + "youtubeLink": "https://www.youtube.com/results?search_query=Straight+Raises+on+Incline+Bench+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Straight+Raises+on+Incline+Bench+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Straight Raises on Incline Bench Shoulders exercise tutorial", + "category": "Shoulders" + }, + "twoarmkettlebellclean": { + "youtubeLink": "https://www.youtube.com/results?search_query=Two-Arm+Kettlebell+Clean+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Two-Arm+Kettlebell+Clean+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Two-Arm Kettlebell Clean Shoulders exercise tutorial", + "category": "Shoulders" + }, + "twoarmkettlebelljerk": { + "youtubeLink": "https://www.youtube.com/results?search_query=Two-Arm+Kettlebell+Jerk+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Two-Arm+Kettlebell+Jerk+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Two-Arm Kettlebell Jerk Shoulders exercise tutorial", + "category": "Shoulders" + }, + "twoarmkettlebellmilitarypress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Two-Arm+Kettlebell+Military+Press+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Two-Arm+Kettlebell+Military+Press+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Two-Arm Kettlebell Military Press Shoulders exercise tutorial", + "category": "Shoulders" + }, + "uprightbarbellrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Upright+Barbell+Row+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Upright+Barbell+Row+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Upright Barbell Row Shoulders exercise tutorial", + "category": "Shoulders" + }, + "upwardstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Upward+Stretch+Shoulders+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Upward+Stretch+Shoulders+exercise+tutorial+shorts", + "youtubeSearchQuery": "Upward Stretch Shoulders exercise tutorial", + "category": "Shoulders" + }, + "barbellshrug": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Shrug+Traps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Shrug+Traps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Shrug Traps exercise tutorial", + "category": "Traps" + }, + "barbellshrugbehindtheback": { + "youtubeLink": "https://www.youtube.com/results?search_query=Barbell+Shrug+Behind+The+Back+Traps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Barbell+Shrug+Behind+The+Back+Traps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Barbell Shrug Behind The Back Traps exercise tutorial", + "category": "Traps" + }, + "cableshrugs": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Shrugs+Traps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Shrugs+Traps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Shrugs Traps exercise tutorial", + "category": "Traps" + }, + "calfmachineshouldershrug": { + "youtubeLink": "https://www.youtube.com/results?search_query=Calf-Machine+Shoulder+Shrug+Traps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Calf-Machine+Shoulder+Shrug+Traps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Calf-Machine Shoulder Shrug Traps exercise tutorial", + "category": "Traps" + }, + "cleanshrug": { + "youtubeLink": "https://www.youtube.com/results?search_query=Clean+Shrug+Traps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Clean+Shrug+Traps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Clean Shrug Traps exercise tutorial", + "category": "Traps" + }, + "dumbbellshrug": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Shrug+Traps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Shrug+Traps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Shrug Traps exercise tutorial", + "category": "Traps" + }, + "kettlebellsumohighpull": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kettlebell+Sumo+High+Pull+Traps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kettlebell+Sumo+High+Pull+Traps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kettlebell Sumo High Pull Traps exercise tutorial", + "category": "Traps" + }, + "leverageshrug": { + "youtubeLink": "https://www.youtube.com/results?search_query=Leverage+Shrug+Traps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Leverage+Shrug+Traps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Leverage Shrug Traps exercise tutorial", + "category": "Traps" + }, + "scapularpullup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Scapular+Pull-Up+Traps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Scapular+Pull-Up+Traps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Scapular Pull-Up Traps exercise tutorial", + "category": "Traps" + }, + "smithmachinebehindthebackshrug": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Behind+the+Back+Shrug+Traps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Behind+the+Back+Shrug+Traps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Behind the Back Shrug Traps exercise tutorial", + "category": "Traps" + }, + "smithmachineuprightrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Upright+Row+Traps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Upright+Row+Traps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Upright Row Traps exercise tutorial", + "category": "Traps" + }, + "snatchshrug": { + "youtubeLink": "https://www.youtube.com/results?search_query=Snatch+Shrug+Traps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Snatch+Shrug+Traps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Snatch Shrug Traps exercise tutorial", + "category": "Traps" + }, + "standingdumbbelluprightrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Dumbbell+Upright+Row+Traps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Dumbbell+Upright+Row+Traps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Dumbbell Upright Row Traps exercise tutorial", + "category": "Traps" + }, + "uprightcablerow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Upright+Cable+Row+Traps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Upright+Cable+Row+Traps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Upright Cable Row Traps exercise tutorial", + "category": "Traps" + }, + "uprightrowwithbands": { + "youtubeLink": "https://www.youtube.com/results?search_query=Upright+Row+-+With+Bands+Traps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Upright+Row+-+With+Bands+Traps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Upright Row - With Bands Traps exercise tutorial", + "category": "Traps" + }, + "bandskullcrusher": { + "youtubeLink": "https://www.youtube.com/results?search_query=Band+Skull+Crusher+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Band+Skull+Crusher+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Band Skull Crusher Triceps exercise tutorial", + "category": "Triceps" + }, + "benchdips": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bench+Dips+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bench+Dips+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bench Dips Triceps exercise tutorial", + "category": "Triceps" + }, + "benchpresspowerlifting": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bench+Press+-+Powerlifting+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bench+Press+-+Powerlifting+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bench Press - Powerlifting Triceps exercise tutorial", + "category": "Triceps" + }, + "benchpresswithchains": { + "youtubeLink": "https://www.youtube.com/results?search_query=Bench+Press+with+Chains+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Bench+Press+with+Chains+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Bench Press with Chains Triceps exercise tutorial", + "category": "Triceps" + }, + "boardpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Board+Press+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Board+Press+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Board Press Triceps exercise tutorial", + "category": "Triceps" + }, + "bodytriceppress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Body+Tricep+Press+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Body+Tricep+Press+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Body Tricep Press Triceps exercise tutorial", + "category": "Triceps" + }, + "bodyup": { + "youtubeLink": "https://www.youtube.com/results?search_query=Body-Up+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Body-Up+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Body-Up Triceps exercise tutorial", + "category": "Triceps" + }, + "cableinclinetricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Incline+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Incline+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Incline Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "cablelyingtricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Lying+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Lying+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Lying Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "cableonearmtricepextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+One+Arm+Tricep+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+One+Arm+Tricep+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable One Arm Tricep Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "cableropeoverheadtricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Cable+Rope+Overhead+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Cable+Rope+Overhead+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Cable Rope Overhead Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "chainhandleextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Chain+Handle+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Chain+Handle+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Chain Handle Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "closegripbarbellbenchpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Close-Grip+Barbell+Bench+Press+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Close-Grip+Barbell+Bench+Press+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Close-Grip Barbell Bench Press Triceps exercise tutorial", + "category": "Triceps" + }, + "closegripdumbbellpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Close-Grip+Dumbbell+Press+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Close-Grip+Dumbbell+Press+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Close-Grip Dumbbell Press Triceps exercise tutorial", + "category": "Triceps" + }, + "closegripezbarpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Close-Grip+EZ-Bar+Press+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Close-Grip+EZ-Bar+Press+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Close-Grip EZ-Bar Press Triceps exercise tutorial", + "category": "Triceps" + }, + "closegrippushupoffofadumbbell": { + "youtubeLink": "https://www.youtube.com/results?search_query=Close-Grip+Push-Up+off+of+a+Dumbbell+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Close-Grip+Push-Up+off+of+a+Dumbbell+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Close-Grip Push-Up off of a Dumbbell Triceps exercise tutorial", + "category": "Triceps" + }, + "declineclosegripbenchtoskullcrusher": { + "youtubeLink": "https://www.youtube.com/results?search_query=Decline+Close-Grip+Bench+To+Skull+Crusher+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Decline+Close-Grip+Bench+To+Skull+Crusher+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Decline Close-Grip Bench To Skull Crusher Triceps exercise tutorial", + "category": "Triceps" + }, + "declinedumbbelltricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Decline+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Decline+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Decline Dumbbell Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "declineezbartricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Decline+EZ+Bar+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Decline+EZ+Bar+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Decline EZ Bar Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "dipmachine": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dip+Machine+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dip+Machine+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dip Machine Triceps exercise tutorial", + "category": "Triceps" + }, + "dipstricepsversion": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dips+-+Triceps+Version+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dips+-+Triceps+Version+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dips - Triceps Version Triceps exercise tutorial", + "category": "Triceps" + }, + "dumbbellfloorpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Floor+Press+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Floor+Press+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Floor Press Triceps exercise tutorial", + "category": "Triceps" + }, + "dumbbellonearmtricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+One-Arm+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+One-Arm+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell One-Arm Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "dumbbelltricepextensionpronatedgrip": { + "youtubeLink": "https://www.youtube.com/results?search_query=Dumbbell+Tricep+Extension+-Pronated+Grip+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Dumbbell+Tricep+Extension+-Pronated+Grip+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Dumbbell Tricep Extension -Pronated Grip Triceps exercise tutorial", + "category": "Triceps" + }, + "ezbarskullcrusher": { + "youtubeLink": "https://www.youtube.com/results?search_query=EZ-Bar+Skullcrusher+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=EZ-Bar+Skullcrusher+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "EZ-Bar Skullcrusher Triceps exercise tutorial", + "category": "Triceps" + }, + "floorpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Floor+Press+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Floor+Press+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Floor Press Triceps exercise tutorial", + "category": "Triceps" + }, + "floorpresswithchains": { + "youtubeLink": "https://www.youtube.com/results?search_query=Floor+Press+with+Chains+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Floor+Press+with+Chains+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Floor Press with Chains Triceps exercise tutorial", + "category": "Triceps" + }, + "inclinebarbelltricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Barbell+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Barbell+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Barbell Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "inclinepushupclosegrip": { + "youtubeLink": "https://www.youtube.com/results?search_query=Incline+Push-Up+Close-Grip+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Incline+Push-Up+Close-Grip+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Incline Push-Up Close-Grip Triceps exercise tutorial", + "category": "Triceps" + }, + "jmpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=JM+Press+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=JM+Press+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "JM Press Triceps exercise tutorial", + "category": "Triceps" + }, + "kneelingcabletricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Kneeling+Cable+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Kneeling+Cable+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Kneeling Cable Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "lowcabletricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Low+Cable+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Low+Cable+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Low Cable Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "lyingclosegripbarbelltricepsextensionbehindthehead": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Close-Grip+Barbell+Triceps+Extension+Behind+The+Head+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Close-Grip+Barbell+Triceps+Extension+Behind+The+Head+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Close-Grip Barbell Triceps Extension Behind The Head Triceps exercise tutorial", + "category": "Triceps" + }, + "lyingclosegripbarbelltricepspresstochin": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Close-Grip+Barbell+Triceps+Press+To+Chin+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Close-Grip+Barbell+Triceps+Press+To+Chin+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Close-Grip Barbell Triceps Press To Chin Triceps exercise tutorial", + "category": "Triceps" + }, + "lyingdumbbelltricepextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Dumbbell+Tricep+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Dumbbell+Tricep+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Dumbbell Tricep Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "lyingtricepspress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Lying+Triceps+Press+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Lying+Triceps+Press+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Lying Triceps Press Triceps exercise tutorial", + "category": "Triceps" + }, + "machinetricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Machine+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Machine+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Machine Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "onearmfloorpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=One+Arm+Floor+Press+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One+Arm+Floor+Press+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "One Arm Floor Press Triceps exercise tutorial", + "category": "Triceps" + }, + "onearmpronateddumbbelltricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=One+Arm+Pronated+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One+Arm+Pronated+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "One Arm Pronated Dumbbell Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "onearmsupinateddumbbelltricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=One+Arm+Supinated+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=One+Arm+Supinated+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "One Arm Supinated Dumbbell Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "overheadtriceps": { + "youtubeLink": "https://www.youtube.com/results?search_query=Overhead+Triceps+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Overhead+Triceps+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Overhead Triceps Triceps exercise tutorial", + "category": "Triceps" + }, + "parallelbardip": { + "youtubeLink": "https://www.youtube.com/results?search_query=Parallel+Bar+Dip+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Parallel+Bar+Dip+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Parallel Bar Dip Triceps exercise tutorial", + "category": "Triceps" + }, + "pinpresses": { + "youtubeLink": "https://www.youtube.com/results?search_query=Pin+Presses+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Pin+Presses+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Pin Presses Triceps exercise tutorial", + "category": "Triceps" + }, + "pushupsclosetricepsposition": { + "youtubeLink": "https://www.youtube.com/results?search_query=Push-Ups+-+Close+Triceps+Position+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Push-Ups+-+Close+Triceps+Position+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Push-Ups - Close Triceps Position Triceps exercise tutorial", + "category": "Triceps" + }, + "reversebandbenchpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Band+Bench+Press+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Band+Bench+Press+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Band Bench Press Triceps exercise tutorial", + "category": "Triceps" + }, + "reversegriptricepspushdown": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Grip+Triceps+Pushdown+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Grip+Triceps+Pushdown+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Grip Triceps Pushdown Triceps exercise tutorial", + "category": "Triceps" + }, + "reversetricepsbenchpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Reverse+Triceps+Bench+Press+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Reverse+Triceps+Bench+Press+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Reverse Triceps Bench Press Triceps exercise tutorial", + "category": "Triceps" + }, + "ringdips": { + "youtubeLink": "https://www.youtube.com/results?search_query=Ring+Dips+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Ring+Dips+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Ring Dips Triceps exercise tutorial", + "category": "Triceps" + }, + "seatedbentoveronearmdumbbelltricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Bent-Over+One-Arm+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Bent-Over+One-Arm+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Bent-Over One-Arm Dumbbell Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "seatedbentovertwoarmdumbbelltricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Bent-Over+Two-Arm+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Bent-Over+Two-Arm+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Bent-Over Two-Arm Dumbbell Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "seatedtricepspress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Seated+Triceps+Press+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Seated+Triceps+Press+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Seated Triceps Press Triceps exercise tutorial", + "category": "Triceps" + }, + "sledoverheadtricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Sled+Overhead+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Sled+Overhead+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Sled Overhead Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "smithmachineclosegripbenchpress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Smith+Machine+Close-Grip+Bench+Press+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Smith+Machine+Close-Grip+Bench+Press+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Smith Machine Close-Grip Bench Press Triceps exercise tutorial", + "category": "Triceps" + }, + "speedbandoverheadtriceps": { + "youtubeLink": "https://www.youtube.com/results?search_query=Speed+Band+Overhead+Triceps+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Speed+Band+Overhead+Triceps+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Speed Band Overhead Triceps Triceps exercise tutorial", + "category": "Triceps" + }, + "standingbentoveronearmdumbbelltricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Bent-Over+One-Arm+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Bent-Over+One-Arm+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Bent-Over One-Arm Dumbbell Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "standingbentovertwoarmdumbbelltricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Bent-Over+Two-Arm+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Bent-Over+Two-Arm+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Bent-Over Two-Arm Dumbbell Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "standingdumbbelltricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Dumbbell Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "standinglowpulleyonearmtricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Low-Pulley+One-Arm+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Low-Pulley+One-Arm+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Low-Pulley One-Arm Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "standingonearmdumbbelltricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+One-Arm+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+One-Arm+Dumbbell+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing One-Arm Dumbbell Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "standingoverheadbarbelltricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Overhead+Barbell+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Overhead+Barbell+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Overhead Barbell Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "standingtoweltricepsextension": { + "youtubeLink": "https://www.youtube.com/results?search_query=Standing+Towel+Triceps+Extension+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Standing+Towel+Triceps+Extension+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Standing Towel Triceps Extension Triceps exercise tutorial", + "category": "Triceps" + }, + "supinechestthrow": { + "youtubeLink": "https://www.youtube.com/results?search_query=Supine+Chest+Throw+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Supine+Chest+Throw+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Supine Chest Throw Triceps exercise tutorial", + "category": "Triceps" + }, + "tatepress": { + "youtubeLink": "https://www.youtube.com/results?search_query=Tate+Press+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Tate+Press+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Tate Press Triceps exercise tutorial", + "category": "Triceps" + }, + "tricepdumbbellkickback": { + "youtubeLink": "https://www.youtube.com/results?search_query=Tricep+Dumbbell+Kickback+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Tricep+Dumbbell+Kickback+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Tricep Dumbbell Kickback Triceps exercise tutorial", + "category": "Triceps" + }, + "tricepsidestretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Tricep+Side+Stretch+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Tricep+Side+Stretch+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Tricep Side Stretch Triceps exercise tutorial", + "category": "Triceps" + }, + "tricepsoverheadextensionwithrope": { + "youtubeLink": "https://www.youtube.com/results?search_query=Triceps+Overhead+Extension+with+Rope+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Triceps+Overhead+Extension+with+Rope+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Triceps Overhead Extension with Rope Triceps exercise tutorial", + "category": "Triceps" + }, + "tricepspushdown": { + "youtubeLink": "https://www.youtube.com/results?search_query=Triceps+Pushdown+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Triceps+Pushdown+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Triceps Pushdown Triceps exercise tutorial", + "category": "Triceps" + }, + "tricepspushdownropeattachment": { + "youtubeLink": "https://www.youtube.com/results?search_query=Triceps+Pushdown+-+Rope+Attachment+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Triceps+Pushdown+-+Rope+Attachment+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Triceps Pushdown - Rope Attachment Triceps exercise tutorial", + "category": "Triceps" + }, + "tricepspushdownvbarattachment": { + "youtubeLink": "https://www.youtube.com/results?search_query=Triceps+Pushdown+-+V-Bar+Attachment+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Triceps+Pushdown+-+V-Bar+Attachment+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Triceps Pushdown - V-Bar Attachment Triceps exercise tutorial", + "category": "Triceps" + }, + "tricepsstretch": { + "youtubeLink": "https://www.youtube.com/results?search_query=Triceps+Stretch+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Triceps+Stretch+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Triceps Stretch Triceps exercise tutorial", + "category": "Triceps" + }, + "weightedbenchdip": { + "youtubeLink": "https://www.youtube.com/results?search_query=Weighted+Bench+Dip+Triceps+exercise+tutorial", + "youtubeShortsLink": "https://www.youtube.com/results?search_query=Weighted+Bench+Dip+Triceps+exercise+tutorial+shorts", + "youtubeSearchQuery": "Weighted Bench Dip Triceps exercise tutorial", + "category": "Triceps" + } +} \ No newline at end of file diff --git a/app/src/main/assets/ironlog/onboarding_template_id.json b/app/src/main/assets/ironlog/onboarding_template_id.json new file mode 100644 index 0000000..c753354 --- /dev/null +++ b/app/src/main/assets/ironlog/onboarding_template_id.json @@ -0,0 +1 @@ +"full_body_beginner_3x" \ No newline at end of file diff --git a/app/src/main/assets/ironlog/program_template_aliases.json b/app/src/main/assets/ironlog/program_template_aliases.json new file mode 100644 index 0000000..1d5cfdc --- /dev/null +++ b/app/src/main/assets/ironlog/program_template_aliases.json @@ -0,0 +1,4 @@ +{ + "bodyweight_calisthenics_beginner": "bodyweight_beginner", + "home_db_bench_legacy": "home_dumbbell_bench_program" +} \ No newline at end of file diff --git a/app/src/main/assets/ironlog/program_template_categories.json b/app/src/main/assets/ironlog/program_template_categories.json new file mode 100644 index 0000000..7dcb862 --- /dev/null +++ b/app/src/main/assets/ironlog/program_template_categories.json @@ -0,0 +1,56 @@ +[ + { + "id": "BEGINNER", + "name": "Beginner", + "description": "Simple foundations.", + "sortOrder": 100 + }, + { + "id": "STRENGTH", + "name": "Strength", + "description": "Low-rep progression.", + "sortOrder": 200 + }, + { + "id": "HYPERTROPHY", + "name": "Hypertrophy", + "description": "High-volume growth.", + "sortOrder": 300 + }, + { + "id": "AESTHETIC", + "name": "Aesthetic", + "description": "Physique-focused training.", + "sortOrder": 400 + }, + { + "id": "LOWER_BODY_GLUTES", + "name": "Lower Body / Glutes", + "description": "Leg emphasis.", + "sortOrder": 500 + }, + { + "id": "SPECIALIZATION", + "name": "Specialization", + "description": "Lagging-muscle focus.", + "sortOrder": 600 + }, + { + "id": "HOME_MINIMAL", + "name": "Home / Minimal Equipment", + "description": "Home-friendly plans.", + "sortOrder": 700 + }, + { + "id": "BODYWEIGHT_CALISTHENICS", + "name": "Bodyweight / Calisthenics", + "description": "Bodyweight-first plans.", + "sortOrder": 800 + }, + { + "id": "FAT_LOSS_CONDITIONING", + "name": "Fat Loss / Conditioning", + "description": "Lifting + conditioning.", + "sortOrder": 900 + } +] \ No newline at end of file diff --git a/app/src/main/assets/ironlog/program_templates.json b/app/src/main/assets/ironlog/program_templates.json new file mode 100644 index 0000000..36ed2bb --- /dev/null +++ b/app/src/main/assets/ironlog/program_templates.json @@ -0,0 +1,7332 @@ +[ + { + "id": "full_body_beginner_3x", + "name": "Full Body Beginner (3x/week)", + "category": "BEGINNER", + "description": "Simple full-body progression.", + "goal": "Build base strength and consistency", + "experienceLevel": "Beginner", + "daysPerWeek": 3, + "equipment": [ + "Barbell", + "Dumbbell", + "Cable", + "Machine", + "Bodyweight" + ], + "isFeatured": true, + "sortOrder": 110, + "blueprint": "FULL_BODY_BEGINNER_3", + "goalBias": "hypertrophy", + "splitType": "full_body", + "minimumEquipment": "barbell", + "sessionLengthTarget": 75, + "recoveryDemand": "low", + "adherenceFloor": 45, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_6_8_weeks_or_3_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "FULL BODY A", + "tag": "Foundation", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 3, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + } + ] + }, + { + "name": "FULL BODY B", + "tag": "Foundation", + "exercises": [ + { + "name": "Deadlift", + "exerciseId": "deadlift", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 2, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "FULL BODY C", + "tag": "Foundation", + "exercises": [ + { + "name": "Front Squat", + "exerciseId": "front-squat", + "sets": 3, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 3, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 2, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + } + ] + } + ] + }, + { + "id": "minimalist_full_body_23", + "name": "Minimalist Full Body (2-3 Day)", + "category": "BEGINNER", + "description": "Time-efficient full-body essentials.", + "goal": "Stay consistent on a busy schedule", + "experienceLevel": "Beginner", + "daysPerWeek": 3, + "equipment": [ + "Barbell", + "Cable", + "Bodyweight" + ], + "isFeatured": true, + "sortOrder": 115, + "blueprint": "MINIMAL_FULL_BODY_3", + "goalBias": "hypertrophy", + "splitType": "full_body", + "minimumEquipment": "barbell", + "sessionLengthTarget": 75, + "recoveryDemand": "low", + "adherenceFloor": 45, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_6_8_weeks_or_3_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "MINIMAL A", + "tag": "Efficient full body", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 3, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 3, + "reps": 8, + "equipment": "Cable" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 3, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "MINIMAL B", + "tag": "Efficient full body", + "exercises": [ + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 3, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 8, + "equipment": "Cable" + }, + { + "name": "Hanging Leg Raise", + "exerciseId": "hanging-leg-raise", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "MINIMAL A", + "tag": "Efficient full body", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 3, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 3, + "reps": 8, + "equipment": "Cable" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 3, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + } + ] + }, + { + "id": "upper_lower_beginner_4x", + "name": "Upper / Lower Beginner (4x/week)", + "category": "BEGINNER", + "description": "Balanced upper/lower split.", + "goal": "Learn movement patterns", + "experienceLevel": "Beginner", + "daysPerWeek": 4, + "equipment": [ + "Barbell", + "Dumbbell", + "Cable", + "Machine", + "Bodyweight" + ], + "isFeatured": true, + "sortOrder": 120, + "blueprint": "UPPER_LOWER_4", + "goalBias": "hypertrophy", + "splitType": "upper_lower", + "minimumEquipment": "barbell", + "sessionLengthTarget": 75, + "recoveryDemand": "moderate", + "adherenceFloor": 58, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_6_8_weeks_or_3_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "UPPER A", + "tag": "Upper split", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Pull-Up", + "exerciseId": "pull-up", + "sets": 3, + "reps": 8, + "equipment": "Bodyweight" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + } + ] + }, + { + "name": "LOWER A", + "tag": "Lower split", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Calf Raise", + "exerciseId": "calf-raise", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + } + ] + }, + { + "name": "UPPER B", + "tag": "Upper split", + "exercises": [ + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "LOWER B", + "tag": "Lower split", + "exercises": [ + { + "name": "Hack Squat", + "exerciseId": "hack-squat", + "sets": 4, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + } + ] + } + ] + }, + { + "id": "bodyweight_beginner", + "name": "Bodyweight Beginner", + "category": "BEGINNER", + "description": "No-gym bodyweight basics.", + "goal": "Build movement control", + "experienceLevel": "Beginner", + "daysPerWeek": 3, + "equipment": [ + "Bodyweight" + ], + "isFeatured": false, + "sortOrder": 130, + "blueprint": "BODYWEIGHT_3", + "goalBias": "hypertrophy", + "splitType": "bodyweight", + "minimumEquipment": "bodyweight", + "sessionLengthTarget": 75, + "recoveryDemand": "low", + "adherenceFloor": 45, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_6_8_weeks_or_3_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "CALI PUSH", + "tag": "Calisthenics", + "exercises": [ + { + "name": "Push-Up", + "exerciseId": "push-up", + "sets": 4, + "reps": 10, + "equipment": "Bodyweight" + }, + { + "name": "Dips", + "exerciseId": "dips", + "sets": 3, + "reps": 8, + "equipment": "Bodyweight" + }, + { + "name": "Push-Up", + "exerciseId": "push-up", + "sets": 3, + "reps": 8, + "equipment": "Bodyweight" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 4, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Sit-Up", + "exerciseId": "situp", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + }, + { + "name": "Pull-Up", + "exerciseId": "pull-up", + "sets": 4, + "reps": 8, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "CALI PULL", + "tag": "Calisthenics", + "exercises": [ + { + "name": "Pull-Up", + "exerciseId": "pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Inverted Row", + "exerciseId": "inverted-row", + "sets": 4, + "reps": 10, + "equipment": "Bodyweight" + }, + { + "name": "Single-Leg Romanian Deadlift", + "exerciseId": "single-leg-romanian-deadlift", + "sets": 3, + "reps": 10, + "equipment": "Bodyweight" + }, + { + "name": "Hanging Leg Raise", + "exerciseId": "hanging_leg_raise", + "sets": 3, + "reps": 10, + "equipment": "Bodyweight" + }, + { + "name": "Reverse Crunch", + "exerciseId": "reverse_crunch", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + }, + { + "name": "Push-Up", + "exerciseId": "push-up", + "sets": 4, + "reps": 15, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "CALI MIXED", + "tag": "Calisthenics", + "exercises": [ + { + "name": "Bodyweight Squat", + "exerciseId": "bodyweight-squat", + "sets": 4, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Split Squat", + "exerciseId": "split-squat", + "sets": 3, + "reps": 10, + "equipment": "Bodyweight" + }, + { + "name": "Glute Bridge", + "exerciseId": "glute-bridge", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Push-Up", + "exerciseId": "push-up", + "sets": 4, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Pull-Up", + "exerciseId": "pull-up", + "sets": 4, + "reps": 8, + "equipment": "Bodyweight" + } + ] + } + ] + }, + { + "id": "upper_lower_strength_4x", + "name": "Upper / Lower Strength Focus (4x/week)", + "category": "STRENGTH", + "description": "Power + volume blend.", + "goal": "Increase compound lifts", + "experienceLevel": "Intermediate", + "daysPerWeek": 4, + "equipment": [ + "Barbell", + "Cable", + "Machine", + "Bodyweight", + "Dumbbell" + ], + "isFeatured": true, + "sortOrder": 210, + "blueprint": "UPPER_LOWER_STRENGTH_4", + "goalBias": "strength", + "splitType": "upper_lower", + "minimumEquipment": "barbell", + "sessionLengthTarget": 75, + "recoveryDemand": "moderate", + "adherenceFloor": 58, + "specializationType": null, + "progressionModel": "linear_progression_with_backoff", + "deloadProtocol": "every_6_8_weeks_or_3_consecutive_underperformances", + "effortTarget": "RIR 1-3 on compounds, RIR 2-3 on accessories", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "UPPER POWER", + "tag": "Strength", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Weighted Pull-Up", + "exerciseId": "weighted-pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 3, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "LOWER POWER", + "tag": "Strength", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 4, + "equipment": "Barbell" + }, + { + "name": "Deadlift", + "exerciseId": "deadlift", + "sets": 2, + "reps": 3, + "equipment": "Barbell" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + } + ] + }, + { + "name": "UPPER B", + "tag": "Upper split", + "exercises": [ + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "LOWER B", + "tag": "Lower split", + "exercises": [ + { + "name": "Hack Squat", + "exerciseId": "hack-squat", + "sets": 4, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + } + ] + } + ] + }, + { + "id": "novice_barbell_strength", + "name": "Novice Barbell Strength", + "category": "STRENGTH", + "description": "Linear barbell progression.", + "goal": "Progress weekly on core lifts", + "experienceLevel": "Beginner", + "daysPerWeek": 3, + "equipment": [ + "Barbell", + "Cable" + ], + "isFeatured": true, + "sortOrder": 220, + "blueprint": "NOVICE_BARBELL_3", + "goalBias": "strength", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 75, + "recoveryDemand": "low", + "adherenceFloor": 45, + "specializationType": null, + "progressionModel": "linear_progression_with_backoff", + "deloadProtocol": "every_6_8_weeks_or_3_consecutive_underperformances", + "effortTarget": "RIR 1-3 on compounds, RIR 2-3 on accessories", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "WORKOUT A", + "tag": "Linear progression", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + } + ] + }, + { + "name": "WORKOUT B", + "tag": "Linear progression", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Deadlift", + "exerciseId": "deadlift", + "sets": 1, + "reps": 5, + "equipment": "Barbell" + } + ] + }, + { + "name": "WORKOUT C", + "tag": "Linear progression", + "exercises": [ + { + "name": "Front Squat", + "exerciseId": "front-squat", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Incline Barbell Press", + "exerciseId": "incline-barbell-press", + "sets": 3, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 8, + "equipment": "Cable" + } + ] + } + ] + }, + { + "id": "powerbuilding_4_day", + "name": "Powerbuilding 4-Day", + "category": "STRENGTH", + "description": "Strength first, size second.", + "goal": "Get stronger while building muscle", + "experienceLevel": "Intermediate", + "daysPerWeek": 4, + "equipment": [ + "Barbell", + "Dumbbell", + "Cable", + "Machine", + "Bodyweight" + ], + "isFeatured": true, + "sortOrder": 230, + "blueprint": "POWERBUILDING_4", + "goalBias": "strength", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 75, + "recoveryDemand": "moderate", + "adherenceFloor": 58, + "specializationType": null, + "progressionModel": "linear_progression_with_backoff", + "deloadProtocol": "every_6_8_weeks_or_3_consecutive_underperformances", + "effortTarget": "RIR 1-3 on compounds, RIR 2-3 on accessories", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "UPPER POWER", + "tag": "Strength", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Weighted Pull-Up", + "exerciseId": "weighted-pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 3, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + } + ] + }, + { + "name": "LOWER POWER", + "tag": "Strength", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 4, + "equipment": "Barbell" + }, + { + "name": "Deadlift", + "exerciseId": "deadlift", + "sets": 2, + "reps": 3, + "equipment": "Barbell" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "PUSH B", + "tag": "Push", + "exercises": [ + { + "name": "Incline Barbell Press", + "exerciseId": "incline-barbell-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Dumbbell Shoulder Press", + "exerciseId": "dumbbell-shoulder-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 4, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Overhead Tricep Extension", + "exerciseId": "overhead-tricep-extension", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "LEGS B", + "tag": "Legs", + "exercises": [ + { + "name": "Front Squat", + "exerciseId": "front-squat", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Hack Squat", + "exerciseId": "hack-squat", + "sets": 4, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + } + ] + }, + { + "id": "powerbuilding_5_day", + "name": "Powerbuilding 5-Day", + "category": "STRENGTH", + "description": "Higher-frequency powerbuilding.", + "goal": "Maximize progression volume", + "experienceLevel": "Intermediate", + "daysPerWeek": 5, + "equipment": [ + "Barbell", + "Dumbbell", + "Cable", + "Machine", + "Bodyweight" + ], + "isFeatured": false, + "sortOrder": 240, + "blueprint": "POWERBUILDING_5", + "goalBias": "strength", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "moderate_high", + "adherenceFloor": 68, + "specializationType": null, + "progressionModel": "linear_progression_with_backoff", + "deloadProtocol": "every_5_7_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on compounds, RIR 2-3 on accessories", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "UPPER POWER", + "tag": "Strength", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Weighted Pull-Up", + "exerciseId": "weighted-pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 3, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + } + ] + }, + { + "name": "LOWER POWER", + "tag": "Strength", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 4, + "equipment": "Barbell" + }, + { + "name": "Deadlift", + "exerciseId": "deadlift", + "sets": 2, + "reps": 3, + "equipment": "Barbell" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "PUSH A", + "tag": "Push", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 4, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Overhead Tricep Extension", + "exerciseId": "overhead-tricep-extension", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "PULL B", + "tag": "Pull", + "exercises": [ + { + "name": "Weighted Pull-Up", + "exerciseId": "weighted-pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Barbell Curl", + "exerciseId": "barbell-curl", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Back Extension", + "exerciseId": "back-extension", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "LEGS B", + "tag": "Legs", + "exercises": [ + { + "name": "Front Squat", + "exerciseId": "front-squat", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Hack Squat", + "exerciseId": "hack-squat", + "sets": 4, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + } + ] + }, + { + "id": "pure_hypertrophy_5_day", + "name": "Pure Hypertrophy 5-Day", + "category": "HYPERTROPHY", + "description": "High-volume growth split.", + "goal": "Maximize hypertrophy", + "experienceLevel": "Intermediate", + "daysPerWeek": 5, + "equipment": [ + "Barbell", + "Dumbbell", + "Cable", + "Machine" + ], + "isFeatured": true, + "sortOrder": 310, + "blueprint": "HYPERTROPHY_5", + "goalBias": "hypertrophy", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "moderate_high", + "adherenceFloor": 68, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_5_7_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "PUSH A", + "tag": "Push", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 4, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Overhead Tricep Extension", + "exerciseId": "overhead-tricep-extension", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "PULL A", + "tag": "Pull", + "exercises": [ + { + "name": "Deadlift", + "exerciseId": "deadlift", + "sets": 4, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Barbell Curl", + "exerciseId": "barbell-curl", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "LEGS A", + "tag": "Legs", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 4, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Calf Raise", + "exerciseId": "calf-raise", + "sets": 4, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + } + ] + }, + { + "name": "UPPER B", + "tag": "Upper split", + "exercises": [ + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "LOWER B", + "tag": "Lower split", + "exercises": [ + { + "name": "Hack Squat", + "exerciseId": "hack-squat", + "sets": 4, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + } + ] + } + ] + }, + { + "id": "ppl_moderate_5x", + "name": "Push / Pull / Legs Moderate (5x/week)", + "category": "HYPERTROPHY", + "description": "Moderate-frequency PPL.", + "goal": "Build muscle with recoverable volume", + "experienceLevel": "Intermediate", + "daysPerWeek": 5, + "equipment": [ + "Barbell", + "Dumbbell", + "Cable", + "Machine", + "Bodyweight" + ], + "isFeatured": true, + "sortOrder": 320, + "blueprint": "PPL_5", + "goalBias": "hypertrophy", + "splitType": "ppl", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "moderate_high", + "adherenceFloor": 68, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_5_7_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "PUSH A", + "tag": "Push", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 4, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Overhead Tricep Extension", + "exerciseId": "overhead-tricep-extension", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "PULL A", + "tag": "Pull", + "exercises": [ + { + "name": "Deadlift", + "exerciseId": "deadlift", + "sets": 4, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Barbell Curl", + "exerciseId": "barbell-curl", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "LEGS A", + "tag": "Legs", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 4, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Calf Raise", + "exerciseId": "calf-raise", + "sets": 4, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + } + ] + }, + { + "name": "PUSH B", + "tag": "Push", + "exercises": [ + { + "name": "Incline Barbell Press", + "exerciseId": "incline-barbell-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Dumbbell Shoulder Press", + "exerciseId": "dumbbell-shoulder-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 4, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Overhead Tricep Extension", + "exerciseId": "overhead-tricep-extension", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "PULL B", + "tag": "Pull", + "exercises": [ + { + "name": "Weighted Pull-Up", + "exerciseId": "weighted-pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Barbell Curl", + "exerciseId": "barbell-curl", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Back Extension", + "exerciseId": "back-extension", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + } + ] + } + ] + }, + { + "id": "ppl_classic_6x", + "name": "Push / Pull / Legs Classic (6x/week)", + "category": "HYPERTROPHY", + "description": "Classic high-frequency PPL.", + "goal": "Maximum training frequency", + "experienceLevel": "Advanced", + "daysPerWeek": 6, + "equipment": [ + "Barbell", + "Dumbbell", + "Cable", + "Machine", + "Bodyweight" + ], + "isFeatured": false, + "sortOrder": 330, + "blueprint": "PPL_6", + "goalBias": "hypertrophy", + "splitType": "ppl", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "high", + "adherenceFloor": 78, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_4_6_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "PUSH A", + "tag": "Push", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 4, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Overhead Tricep Extension", + "exerciseId": "overhead-tricep-extension", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "PULL A", + "tag": "Pull", + "exercises": [ + { + "name": "Deadlift", + "exerciseId": "deadlift", + "sets": 4, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Barbell Curl", + "exerciseId": "barbell-curl", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "LEGS A", + "tag": "Legs", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 4, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Calf Raise", + "exerciseId": "calf-raise", + "sets": 4, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + } + ] + }, + { + "name": "PUSH B", + "tag": "Push", + "exercises": [ + { + "name": "Incline Barbell Press", + "exerciseId": "incline-barbell-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Dumbbell Shoulder Press", + "exerciseId": "dumbbell-shoulder-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 4, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Overhead Tricep Extension", + "exerciseId": "overhead-tricep-extension", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "PULL B", + "tag": "Pull", + "exercises": [ + { + "name": "Weighted Pull-Up", + "exerciseId": "weighted-pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Barbell Curl", + "exerciseId": "barbell-curl", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Back Extension", + "exerciseId": "back-extension", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "LEGS B", + "tag": "Legs", + "exercises": [ + { + "name": "Front Squat", + "exerciseId": "front-squat", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Hack Squat", + "exerciseId": "hack-squat", + "sets": 4, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + } + ] + }, + { + "id": "bro_split_5x", + "name": "Bro Split (5x/week)", + "category": "HYPERTROPHY", + "description": "Single-muscle focus days.", + "goal": "Body-part specialization", + "experienceLevel": "Intermediate", + "daysPerWeek": 5, + "equipment": [ + "Barbell", + "Dumbbell", + "Cable", + "Machine", + "Bodyweight" + ], + "isFeatured": false, + "sortOrder": 340, + "blueprint": "BRO_SPLIT_5", + "goalBias": "hypertrophy", + "splitType": "bro_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "moderate_high", + "adherenceFloor": 68, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_5_7_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "CHEST", + "tag": "Bro split", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 4, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Skull Crusher", + "exerciseId": "skull-crusher", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 4, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "BACK", + "tag": "Bro split", + "exercises": [ + { + "name": "Weighted Pull-Up", + "exerciseId": "weighted-pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "SHOULDERS", + "tag": "Bro split", + "exercises": [ + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Dumbbell Shoulder Press", + "exerciseId": "dumbbell-shoulder-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 5, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 4, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "ARMS", + "tag": "Bro split", + "exercises": [ + { + "name": "Barbell Curl", + "exerciseId": "barbell-curl", + "sets": 4, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 4, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 4, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Overhead Tricep Extension", + "exerciseId": "overhead-tricep-extension", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "EZ-Bar Curl", + "exerciseId": "ez-bar-curl", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Incline Dumbbell Curl", + "exerciseId": "incline-dumbbell-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "LEGS A", + "tag": "Legs", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 4, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Calf Raise", + "exerciseId": "calf-raise", + "sets": 4, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + } + ] + } + ] + }, + { + "id": "arnold_split_6x", + "name": "Arnold Split (6x/week)", + "category": "HYPERTROPHY", + "description": "Classic high-volume pairing split.", + "goal": "Increase upper-body density", + "experienceLevel": "Advanced", + "daysPerWeek": 6, + "equipment": [ + "Barbell", + "Dumbbell", + "Cable", + "Machine" + ], + "isFeatured": false, + "sortOrder": 350, + "blueprint": "ARNOLD_6", + "goalBias": "hypertrophy", + "splitType": "arnold", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "high", + "adherenceFloor": 78, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_4_6_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "CHEST + BACK (HEAVY)", + "tag": "Arnold", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 8, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 8, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 4, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 15, + "equipment": "Cable" + } + ] + }, + { + "name": "SHOULDERS + ARMS (HEAVY)", + "tag": "Arnold", + "exercises": [ + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Barbell Curl", + "exerciseId": "barbell-curl", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Skull Crusher", + "exerciseId": "skull-crusher", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "LEGS (HEAVY)", + "tag": "Arnold", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + } + ] + }, + { + "name": "CHEST + BACK (VOLUME)", + "tag": "Arnold", + "exercises": [ + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 4, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "SHOULDERS + ARMS (VOLUME)", + "tag": "Arnold", + "exercises": [ + { + "name": "Dumbbell Shoulder Press", + "exerciseId": "dumbbell-shoulder-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 4, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Overhead Tricep Extension", + "exerciseId": "overhead-tricep-extension", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "LEGS (VOLUME)", + "tag": "Arnold", + "exercises": [ + { + "name": "Hack Squat", + "exerciseId": "hack-squat", + "sets": 4, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + } + ] + } + ] + }, + { + "id": "mens_aesthetic_v_taper", + "name": "Men's Aesthetic V-Taper Program", + "category": "AESTHETIC", + "description": "Shoulders and lats emphasis.", + "goal": "Improve upper-body proportions", + "experienceLevel": "Intermediate", + "daysPerWeek": 5, + "equipment": [ + "Barbell", + "Dumbbell", + "Cable", + "Machine", + "Bodyweight" + ], + "isFeatured": true, + "sortOrder": 410, + "blueprint": "AESTHETIC_5", + "goalBias": "aesthetics", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "moderate_high", + "adherenceFloor": 68, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_5_7_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "UPPER B", + "tag": "Upper split", + "exercises": [ + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "LOWER A", + "tag": "Lower split", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Calf Raise", + "exerciseId": "calf-raise", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + } + ] + }, + { + "name": "SHOULDERS", + "tag": "Bro split", + "exercises": [ + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Dumbbell Shoulder Press", + "exerciseId": "dumbbell-shoulder-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 5, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 4, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "BACK", + "tag": "Bro split", + "exercises": [ + { + "name": "Weighted Pull-Up", + "exerciseId": "weighted-pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "ARMS", + "tag": "Bro split", + "exercises": [ + { + "name": "Barbell Curl", + "exerciseId": "barbell-curl", + "sets": 4, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 4, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 4, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Overhead Tricep Extension", + "exerciseId": "overhead-tricep-extension", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "EZ-Bar Curl", + "exerciseId": "ez-bar-curl", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Incline Dumbbell Curl", + "exerciseId": "incline-dumbbell-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + } + ] + }, + { + "id": "lean_physique_recomp", + "name": "Lean Physique Recomp Program", + "category": "AESTHETIC", + "description": "Lifting + conditioning recomposition.", + "goal": "Build muscle while cutting fat", + "experienceLevel": "Intermediate", + "daysPerWeek": 5, + "equipment": [ + "Barbell", + "Dumbbell", + "Cable", + "Machine", + "Conditioning", + "Bodyweight" + ], + "isFeatured": true, + "sortOrder": 420, + "blueprint": "RECOMP_5", + "goalBias": "fat_loss", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "moderate_high", + "adherenceFloor": 68, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_5_7_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "UPPER A", + "tag": "Upper split", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Pull-Up", + "exerciseId": "pull-up", + "sets": 3, + "reps": 8, + "equipment": "Bodyweight" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + } + ] + }, + { + "name": "LOWER A", + "tag": "Lower split", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Calf Raise", + "exerciseId": "calf-raise", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + } + ] + }, + { + "name": "CONDITIONING", + "tag": "Engine", + "exercises": [ + { + "name": "Air Bike", + "exerciseId": "air_bike", + "sets": 6, + "reps": 20, + "equipment": "Conditioning" + }, + { + "name": "Reverse Crunch", + "exerciseId": "reverse_crunch", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 4, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Running", + "exerciseId": "running", + "sets": 4, + "reps": 400, + "equipment": "Conditioning" + }, + { + "name": "Spider Crawl", + "exerciseId": "spider_crawl", + "sets": 4, + "reps": 20, + "equipment": "Bodyweight" + }, + { + "name": "Hanging Leg Raise", + "exerciseId": "hanging-leg-raise", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "UPPER B", + "tag": "Upper split", + "exercises": [ + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "LOWER B", + "tag": "Lower split", + "exercises": [ + { + "name": "Hack Squat", + "exerciseId": "hack-squat", + "sets": 4, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + } + ] + } + ] + }, + { + "id": "upper_body_bias_program", + "name": "Upper Body Bias Program", + "category": "AESTHETIC", + "description": "Upper-focused with lower maintenance.", + "goal": "Bring up upper body faster", + "experienceLevel": "Intermediate", + "daysPerWeek": 4, + "equipment": [ + "Barbell", + "Dumbbell", + "Cable", + "Machine", + "Bodyweight" + ], + "isFeatured": false, + "sortOrder": 430, + "blueprint": "UPPER_BIAS_4", + "goalBias": "aesthetics", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 75, + "recoveryDemand": "moderate", + "adherenceFloor": 58, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_6_8_weeks_or_3_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "UPPER POWER", + "tag": "Strength", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Weighted Pull-Up", + "exerciseId": "weighted-pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 3, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + } + ] + }, + { + "name": "LOWER A", + "tag": "Lower split", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Calf Raise", + "exerciseId": "calf-raise", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + } + ] + }, + { + "name": "UPPER B", + "tag": "Upper split", + "exercises": [ + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "SHOULDERS", + "tag": "Bro split", + "exercises": [ + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Dumbbell Shoulder Press", + "exerciseId": "dumbbell-shoulder-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 5, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 4, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + } + ] + }, + { + "id": "glute_legs_focus_45", + "name": "Glute & Legs Focus (4-5x/week)", + "category": "LOWER_BODY_GLUTES", + "description": "Lower-body dominant volume.", + "goal": "Prioritize glutes and legs", + "experienceLevel": "Intermediate", + "daysPerWeek": 4, + "equipment": [ + "Barbell", + "Machine", + "Dumbbell", + "Cable" + ], + "isFeatured": true, + "sortOrder": 510, + "blueprint": "GLUTES_LEGS_5", + "goalBias": "hypertrophy", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 75, + "recoveryDemand": "moderate", + "adherenceFloor": 58, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_6_8_weeks_or_3_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "GLUTE + QUAD", + "tag": "Lower focus", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 2, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 12, + "equipment": "Machine" + } + ] + }, + { + "name": "UPPER B", + "tag": "Upper split", + "exercises": [ + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "GLUTE + HAM", + "tag": "Lower focus", + "exercises": [ + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "GLUTE PUMP", + "tag": "Lower focus", + "exercises": [ + { + "name": "Hack Squat", + "exerciseId": "hack-squat", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 3, + "reps": 12, + "equipment": "Barbell" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 2, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 12, + "equipment": "Machine" + } + ] + } + ] + }, + { + "id": "womens_lower_body_focus", + "name": "Women's Lower Body Focus", + "category": "LOWER_BODY_GLUTES", + "description": "Lower-body emphasis with upper support.", + "goal": "Lower-body growth and strength", + "experienceLevel": "Beginner", + "daysPerWeek": 4, + "equipment": [ + "Barbell", + "Machine", + "Dumbbell", + "Cable" + ], + "isFeatured": true, + "sortOrder": 520, + "blueprint": "LOWER_FOCUS_4", + "goalBias": "hypertrophy", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 75, + "recoveryDemand": "moderate", + "adherenceFloor": 58, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_6_8_weeks_or_3_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "LOWER A", + "tag": "Lower split", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Calf Raise", + "exerciseId": "calf-raise", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + } + ] + }, + { + "name": "UPPER B", + "tag": "Upper split", + "exercises": [ + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "LOWER B", + "tag": "Lower split", + "exercises": [ + { + "name": "Hack Squat", + "exerciseId": "hack-squat", + "sets": 4, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + } + ] + }, + { + "name": "LEGS B", + "tag": "Legs", + "exercises": [ + { + "name": "Front Squat", + "exerciseId": "front-squat", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Hack Squat", + "exerciseId": "hack-squat", + "sets": 4, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + } + ] + }, + { + "id": "athletic_legs_program", + "name": "Athletic Legs Program", + "category": "LOWER_BODY_GLUTES", + "description": "Power + conditioning lower split.", + "goal": "Athletic lower-body performance", + "experienceLevel": "Intermediate", + "daysPerWeek": 4, + "equipment": [ + "Barbell", + "Machine", + "Conditioning", + "Bodyweight" + ], + "isFeatured": false, + "sortOrder": 530, + "blueprint": "ATHLETIC_LEGS_4", + "goalBias": "hypertrophy", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 75, + "recoveryDemand": "moderate", + "adherenceFloor": 58, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_6_8_weeks_or_3_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "LOWER POWER", + "tag": "Strength", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 4, + "equipment": "Barbell" + }, + { + "name": "Deadlift", + "exerciseId": "deadlift", + "sets": 2, + "reps": 3, + "equipment": "Barbell" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + } + ] + }, + { + "name": "CONDITIONING", + "tag": "Engine", + "exercises": [ + { + "name": "Air Bike", + "exerciseId": "air_bike", + "sets": 6, + "reps": 20, + "equipment": "Conditioning" + }, + { + "name": "Reverse Crunch", + "exerciseId": "reverse_crunch", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 4, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Running", + "exerciseId": "running", + "sets": 4, + "reps": 400, + "equipment": "Conditioning" + }, + { + "name": "Spider Crawl", + "exerciseId": "spider_crawl", + "sets": 4, + "reps": 20, + "equipment": "Bodyweight" + }, + { + "name": "Hanging Leg Raise", + "exerciseId": "hanging-leg-raise", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "LEGS A", + "tag": "Legs", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 4, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Calf Raise", + "exerciseId": "calf-raise", + "sets": 4, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + } + ] + }, + { + "name": "UPPER A", + "tag": "Upper split", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Pull-Up", + "exerciseId": "pull-up", + "sets": 3, + "reps": 8, + "equipment": "Bodyweight" + } + ] + } + ] + }, + { + "id": "arm_specialization_block", + "name": "Arm Specialization Block", + "category": "SPECIALIZATION", + "description": "Extra direct arm volume.", + "goal": "Bring up biceps and triceps", + "experienceLevel": "Intermediate", + "daysPerWeek": 5, + "equipment": [ + "Barbell", + "Dumbbell", + "Cable", + "Bodyweight", + "Machine" + ], + "isFeatured": false, + "sortOrder": 610, + "blueprint": "ARM_SPECIALIZATION_5", + "goalBias": "specialization", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "moderate_high", + "adherenceFloor": 68, + "specializationType": "arms", + "progressionModel": "double_progression", + "deloadProtocol": "every_5_7_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": 4, + "guardrailNotes": "Prioritize target muscle volume, keep non-target work at maintenance, then pivot after block.", + "days": [ + { + "name": "ARMS", + "tag": "Bro split", + "exercises": [ + { + "name": "Barbell Curl", + "exerciseId": "barbell-curl", + "sets": 4, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 4, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 4, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Overhead Tricep Extension", + "exerciseId": "overhead-tricep-extension", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "EZ-Bar Curl", + "exerciseId": "ez-bar-curl", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Incline Dumbbell Curl", + "exerciseId": "incline-dumbbell-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "UPPER A", + "tag": "Upper split", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Pull-Up", + "exerciseId": "pull-up", + "sets": 3, + "reps": 8, + "equipment": "Bodyweight" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + } + ] + }, + { + "name": "PULL B", + "tag": "Pull", + "exercises": [ + { + "name": "Weighted Pull-Up", + "exerciseId": "weighted-pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Barbell Curl", + "exerciseId": "barbell-curl", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Back Extension", + "exerciseId": "back-extension", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "ARMS", + "tag": "Bro split", + "exercises": [ + { + "name": "Barbell Curl", + "exerciseId": "barbell-curl", + "sets": 4, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 4, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 4, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Overhead Tricep Extension", + "exerciseId": "overhead-tricep-extension", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "EZ-Bar Curl", + "exerciseId": "ez-bar-curl", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Incline Dumbbell Curl", + "exerciseId": "incline-dumbbell-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "LOWER A", + "tag": "Lower split", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Calf Raise", + "exerciseId": "calf-raise", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + } + ] + } + ] + }, + { + "id": "chest_specialization_block", + "name": "Chest Specialization Block", + "category": "SPECIALIZATION", + "description": "Chest-priority frequency block.", + "goal": "Prioritize chest development", + "experienceLevel": "Intermediate", + "daysPerWeek": 5, + "equipment": [ + "Barbell", + "Dumbbell", + "Cable", + "Bodyweight", + "Machine" + ], + "isFeatured": false, + "sortOrder": 620, + "blueprint": "CHEST_SPECIALIZATION_5", + "goalBias": "specialization", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "moderate_high", + "adherenceFloor": 68, + "specializationType": "chest", + "progressionModel": "double_progression", + "deloadProtocol": "every_5_7_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": 4, + "guardrailNotes": "Prioritize target muscle volume, keep non-target work at maintenance, then pivot after block.", + "days": [ + { + "name": "CHEST", + "tag": "Bro split", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 4, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Skull Crusher", + "exerciseId": "skull-crusher", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 4, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "UPPER A", + "tag": "Upper split", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Pull-Up", + "exerciseId": "pull-up", + "sets": 3, + "reps": 8, + "equipment": "Bodyweight" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + } + ] + }, + { + "name": "LOWER A", + "tag": "Lower split", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Calf Raise", + "exerciseId": "calf-raise", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + } + ] + }, + { + "name": "CHEST", + "tag": "Bro split", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 4, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Skull Crusher", + "exerciseId": "skull-crusher", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 4, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "UPPER B", + "tag": "Upper split", + "exercises": [ + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + } + ] + }, + { + "id": "back_width_thickness_block", + "name": "Back Width + Thickness Block", + "category": "SPECIALIZATION", + "description": "Dual-focus back growth block.", + "goal": "Increase back width and thickness", + "experienceLevel": "Intermediate", + "daysPerWeek": 5, + "equipment": [ + "Barbell", + "Cable", + "Bodyweight", + "Dumbbell", + "Machine" + ], + "isFeatured": false, + "sortOrder": 630, + "blueprint": "BACK_SPECIALIZATION_5", + "goalBias": "specialization", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "moderate_high", + "adherenceFloor": 68, + "specializationType": "back", + "progressionModel": "double_progression", + "deloadProtocol": "every_5_7_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": 4, + "guardrailNotes": "Prioritize target muscle volume, keep non-target work at maintenance, then pivot after block.", + "days": [ + { + "name": "BACK", + "tag": "Bro split", + "exercises": [ + { + "name": "Weighted Pull-Up", + "exerciseId": "weighted-pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "PULL A", + "tag": "Pull", + "exercises": [ + { + "name": "Deadlift", + "exerciseId": "deadlift", + "sets": 4, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Barbell Curl", + "exerciseId": "barbell-curl", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "LOWER A", + "tag": "Lower split", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Calf Raise", + "exerciseId": "calf-raise", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + } + ] + }, + { + "name": "BACK", + "tag": "Bro split", + "exercises": [ + { + "name": "Weighted Pull-Up", + "exerciseId": "weighted-pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "ARMS", + "tag": "Bro split", + "exercises": [ + { + "name": "Barbell Curl", + "exerciseId": "barbell-curl", + "sets": 4, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 4, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 4, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Overhead Tricep Extension", + "exerciseId": "overhead-tricep-extension", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "EZ-Bar Curl", + "exerciseId": "ez-bar-curl", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + }, + { + "name": "Incline Dumbbell Curl", + "exerciseId": "incline-dumbbell-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + } + ] + }, + { + "id": "shoulder_specialization_block", + "name": "Shoulder Specialization Block", + "category": "SPECIALIZATION", + "description": "High-frequency delt block.", + "goal": "Build rounder shoulders", + "experienceLevel": "Intermediate", + "daysPerWeek": 5, + "equipment": [ + "Barbell", + "Dumbbell", + "Cable", + "Bodyweight", + "Machine" + ], + "isFeatured": false, + "sortOrder": 640, + "blueprint": "SHOULDER_SPECIALIZATION_5", + "goalBias": "specialization", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "moderate_high", + "adherenceFloor": 68, + "specializationType": "shoulders", + "progressionModel": "double_progression", + "deloadProtocol": "every_5_7_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": 4, + "guardrailNotes": "Prioritize target muscle volume, keep non-target work at maintenance, then pivot after block.", + "days": [ + { + "name": "SHOULDERS", + "tag": "Bro split", + "exercises": [ + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Dumbbell Shoulder Press", + "exerciseId": "dumbbell-shoulder-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 5, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 4, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "UPPER A", + "tag": "Upper split", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Pull-Up", + "exerciseId": "pull-up", + "sets": 3, + "reps": 8, + "equipment": "Bodyweight" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + } + ] + }, + { + "name": "LOWER A", + "tag": "Lower split", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Leg Press", + "exerciseId": "leg-press", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Calf Raise", + "exerciseId": "calf-raise", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + } + ] + }, + { + "name": "SHOULDERS", + "tag": "Bro split", + "exercises": [ + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Dumbbell Shoulder Press", + "exerciseId": "dumbbell-shoulder-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 5, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 4, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "PUSH B", + "tag": "Push", + "exercises": [ + { + "name": "Incline Barbell Press", + "exerciseId": "incline-barbell-press", + "sets": 4, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Dumbbell Shoulder Press", + "exerciseId": "dumbbell-shoulder-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 4, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Overhead Tricep Extension", + "exerciseId": "overhead-tricep-extension", + "sets": 3, + "reps": 12, + "equipment": "Cable" + }, + { + "name": "Cable Fly", + "exerciseId": "cable-fly", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + } + ] + }, + { + "id": "home_dumbbell_only_program", + "name": "Home Dumbbell Only Program", + "category": "HOME_MINIMAL", + "description": "No-machine home split.", + "goal": "Effective home training", + "experienceLevel": "All Levels", + "daysPerWeek": 4, + "equipment": [ + "Dumbbell", + "Bodyweight" + ], + "isFeatured": true, + "sortOrder": 710, + "blueprint": "HOME_DB_ONLY_4", + "goalBias": "hypertrophy", + "splitType": "home_split", + "minimumEquipment": "dumbbell", + "sessionLengthTarget": 75, + "recoveryDemand": "moderate", + "adherenceFloor": 58, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_6_8_weeks_or_3_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Only use equipment listed in the plan header.", + "days": [ + { + "name": "HOME UPPER", + "tag": "Dumbbell only", + "exercises": [ + { + "name": "Dumbbell Bench Press", + "exerciseId": "dumbbell-bench-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Dumbbell Shoulder Press", + "exerciseId": "dumbbell-shoulder-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "One-Arm Dumbbell Row", + "exerciseId": "one-arm-dumbbell-row", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "HOME LOWER", + "tag": "Dumbbell only", + "exercises": [ + { + "name": "Goblet Squat", + "exerciseId": "goblet-squat", + "sets": 4, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 4, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 3, + "reps": 60, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "HOME UPPER", + "tag": "Dumbbell only", + "exercises": [ + { + "name": "Dumbbell Bench Press", + "exerciseId": "dumbbell-bench-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Dumbbell Shoulder Press", + "exerciseId": "dumbbell-shoulder-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "One-Arm Dumbbell Row", + "exerciseId": "one-arm-dumbbell-row", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "HOME LOWER", + "tag": "Dumbbell only", + "exercises": [ + { + "name": "Goblet Squat", + "exerciseId": "goblet-squat", + "sets": 4, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 4, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 3, + "reps": 60, + "equipment": "Bodyweight" + } + ] + } + ] + }, + { + "id": "home_dumbbell_bench_program", + "name": "Home Dumbbell + Bench Program", + "category": "HOME_MINIMAL", + "description": "Home split with bench options.", + "goal": "Expand home exercise variety", + "experienceLevel": "All Levels", + "daysPerWeek": 4, + "equipment": [ + "Dumbbell", + "Bench", + "Bodyweight" + ], + "isFeatured": true, + "sortOrder": 720, + "blueprint": "HOME_DB_BENCH_4", + "goalBias": "hypertrophy", + "splitType": "home_split", + "minimumEquipment": "dumbbell", + "sessionLengthTarget": 75, + "recoveryDemand": "moderate", + "adherenceFloor": 58, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_6_8_weeks_or_3_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Only use equipment listed in the plan header.", + "days": [ + { + "name": "HOME PUSH", + "tag": "Dumbbell + bench", + "exercises": [ + { + "name": "Dumbbell Bench Press", + "exerciseId": "dumbbell-bench-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Dumbbell Shoulder Press", + "exerciseId": "dumbbell-shoulder-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "HOME LOWER", + "tag": "Dumbbell only", + "exercises": [ + { + "name": "Goblet Squat", + "exerciseId": "goblet-squat", + "sets": 4, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 4, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 3, + "reps": 60, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "HOME PULL", + "tag": "Dumbbell + bench", + "exercises": [ + { + "name": "One-Arm Dumbbell Row", + "exerciseId": "one-arm-dumbbell-row", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "One-Arm Dumbbell Row", + "exerciseId": "one-arm-dumbbell-row", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Incline Dumbbell Curl", + "exerciseId": "incline-dumbbell-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Back Extension", + "exerciseId": "back-extension", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "HOME LOWER", + "tag": "Dumbbell only", + "exercises": [ + { + "name": "Goblet Squat", + "exerciseId": "goblet-squat", + "sets": 4, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Romanian Deadlift", + "exerciseId": "romanian-deadlift", + "sets": 4, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 3, + "reps": 60, + "equipment": "Bodyweight" + } + ] + } + ] + }, + { + "id": "resistance_band_only_program", + "name": "Resistance Band Only Program", + "category": "HOME_MINIMAL", + "description": "Portable resistance-band routine.", + "goal": "Minimal-equipment consistency", + "experienceLevel": "All Levels", + "daysPerWeek": 4, + "equipment": [ + "Resistance Band", + "Bodyweight" + ], + "isFeatured": false, + "sortOrder": 730, + "blueprint": "BAND_ONLY_4", + "goalBias": "hypertrophy", + "splitType": "specialized_split", + "minimumEquipment": "resistance_band", + "sessionLengthTarget": 75, + "recoveryDemand": "moderate", + "adherenceFloor": 58, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_6_8_weeks_or_3_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Only use equipment listed in the plan header.", + "days": [ + { + "name": "BAND UPPER", + "tag": "Band only", + "exercises": [ + { + "name": "Push-Up", + "exerciseId": "push-up", + "sets": 4, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Band Row", + "exerciseId": "band-row", + "sets": 4, + "reps": 15, + "equipment": "Resistance Band" + }, + { + "name": "Push-Up", + "exerciseId": "push-up", + "sets": 4, + "reps": 12, + "equipment": "Bodyweight" + }, + { + "name": "Band Curl", + "exerciseId": "band-curl", + "sets": 3, + "reps": 15, + "equipment": "Resistance Band" + } + ] + }, + { + "name": "BAND LOWER", + "tag": "Band only", + "exercises": [ + { + "name": "Band Squat", + "exerciseId": "band-squat", + "sets": 4, + "reps": 15, + "equipment": "Resistance Band" + }, + { + "name": "Band Squat", + "exerciseId": "band-squat", + "sets": 4, + "reps": 15, + "equipment": "Resistance Band" + }, + { + "name": "Glute Bridge", + "exerciseId": "glute-bridge", + "sets": 4, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Band Calf Raise", + "exerciseId": "band-calf-raise", + "sets": 4, + "reps": 20, + "equipment": "Resistance Band" + } + ] + }, + { + "name": "BAND UPPER", + "tag": "Band only", + "exercises": [ + { + "name": "Push-Up", + "exerciseId": "push-up", + "sets": 4, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Band Row", + "exerciseId": "band-row", + "sets": 4, + "reps": 15, + "equipment": "Resistance Band" + }, + { + "name": "Push-Up", + "exerciseId": "push-up", + "sets": 4, + "reps": 12, + "equipment": "Bodyweight" + }, + { + "name": "Band Curl", + "exerciseId": "band-curl", + "sets": 3, + "reps": 15, + "equipment": "Resistance Band" + } + ] + }, + { + "name": "BAND LOWER", + "tag": "Band only", + "exercises": [ + { + "name": "Band Squat", + "exerciseId": "band-squat", + "sets": 4, + "reps": 15, + "equipment": "Resistance Band" + }, + { + "name": "Band Squat", + "exerciseId": "band-squat", + "sets": 4, + "reps": 15, + "equipment": "Resistance Band" + }, + { + "name": "Glute Bridge", + "exerciseId": "glute-bridge", + "sets": 4, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Band Calf Raise", + "exerciseId": "band-calf-raise", + "sets": 4, + "reps": 20, + "equipment": "Resistance Band" + } + ] + } + ] + }, + { + "id": "calisthenics_strength_base", + "name": "Calisthenics Strength Base", + "category": "BODYWEIGHT_CALISTHENICS", + "description": "Bodyweight strength progression.", + "goal": "Increase bodyweight strength", + "experienceLevel": "Intermediate", + "daysPerWeek": 4, + "equipment": [ + "Bodyweight" + ], + "isFeatured": false, + "sortOrder": 820, + "blueprint": "CALISTHENICS_STRENGTH_4", + "goalBias": "hypertrophy", + "splitType": "bodyweight", + "minimumEquipment": "bodyweight", + "sessionLengthTarget": 75, + "recoveryDemand": "moderate", + "adherenceFloor": 58, + "specializationType": null, + "progressionModel": "double_progression", + "deloadProtocol": "every_6_8_weeks_or_3_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": null, + "guardrailNotes": "Progress by quality reps first, then load. Deload when performance stalls.", + "days": [ + { + "name": "CALI PUSH", + "tag": "Calisthenics", + "exercises": [ + { + "name": "Push-Up", + "exerciseId": "push-up", + "sets": 4, + "reps": 10, + "equipment": "Bodyweight" + }, + { + "name": "Dips", + "exerciseId": "dips", + "sets": 3, + "reps": 8, + "equipment": "Bodyweight" + }, + { + "name": "Push-Up", + "exerciseId": "push-up", + "sets": 3, + "reps": 8, + "equipment": "Bodyweight" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 4, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Sit-Up", + "exerciseId": "situp", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + }, + { + "name": "Pull-Up", + "exerciseId": "pull-up", + "sets": 4, + "reps": 8, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "CALI PULL", + "tag": "Calisthenics", + "exercises": [ + { + "name": "Pull-Up", + "exerciseId": "pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Inverted Row", + "exerciseId": "inverted-row", + "sets": 4, + "reps": 10, + "equipment": "Bodyweight" + }, + { + "name": "Single-Leg Romanian Deadlift", + "exerciseId": "single-leg-romanian-deadlift", + "sets": 3, + "reps": 10, + "equipment": "Bodyweight" + }, + { + "name": "Hanging Leg Raise", + "exerciseId": "hanging_leg_raise", + "sets": 3, + "reps": 10, + "equipment": "Bodyweight" + }, + { + "name": "Reverse Crunch", + "exerciseId": "reverse_crunch", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + }, + { + "name": "Push-Up", + "exerciseId": "push-up", + "sets": 4, + "reps": 15, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "CALI MIXED", + "tag": "Calisthenics", + "exercises": [ + { + "name": "Bodyweight Squat", + "exerciseId": "bodyweight-squat", + "sets": 4, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Split Squat", + "exerciseId": "split-squat", + "sets": 3, + "reps": 10, + "equipment": "Bodyweight" + }, + { + "name": "Glute Bridge", + "exerciseId": "glute-bridge", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Push-Up", + "exerciseId": "push-up", + "sets": 4, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Pull-Up", + "exerciseId": "pull-up", + "sets": 4, + "reps": 8, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "CALI PULL", + "tag": "Calisthenics", + "exercises": [ + { + "name": "Pull-Up", + "exerciseId": "pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Inverted Row", + "exerciseId": "inverted-row", + "sets": 4, + "reps": 10, + "equipment": "Bodyweight" + }, + { + "name": "Single-Leg Romanian Deadlift", + "exerciseId": "single-leg-romanian-deadlift", + "sets": 3, + "reps": 10, + "equipment": "Bodyweight" + }, + { + "name": "Hanging Leg Raise", + "exerciseId": "hanging_leg_raise", + "sets": 3, + "reps": 10, + "equipment": "Bodyweight" + }, + { + "name": "Reverse Crunch", + "exerciseId": "reverse_crunch", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + }, + { + "name": "Push-Up", + "exerciseId": "push-up", + "sets": 4, + "reps": 15, + "equipment": "Bodyweight" + } + ] + } + ] + }, + { + "id": "fat_loss_conditioning_lifting_hybrid", + "name": "Fat Loss Conditioning + Lifting Hybrid", + "category": "FAT_LOSS_CONDITIONING", + "description": "Alternating lift and conditioning days.", + "goal": "Lose fat while preserving strength", + "experienceLevel": "Intermediate", + "daysPerWeek": 5, + "equipment": [ + "Barbell", + "Dumbbell", + "Conditioning", + "Bodyweight", + "Cable" + ], + "isFeatured": true, + "sortOrder": 910, + "blueprint": "FAT_LOSS_HYBRID_5", + "goalBias": "fat_loss", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "moderate_high", + "adherenceFloor": 68, + "specializationType": null, + "progressionModel": "double_progression_conservative", + "deloadProtocol": "every_5_7_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": 6, + "guardrailNotes": "Separate hard conditioning from heavy lower sessions when possible and scale intervals by recovery.", + "days": [ + { + "name": "FULL BODY A", + "tag": "Foundation", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 3, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "CONDITIONING", + "tag": "Engine", + "exercises": [ + { + "name": "Air Bike", + "exerciseId": "air_bike", + "sets": 6, + "reps": 20, + "equipment": "Conditioning" + }, + { + "name": "Reverse Crunch", + "exerciseId": "reverse_crunch", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 4, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Running", + "exerciseId": "running", + "sets": 4, + "reps": 400, + "equipment": "Conditioning" + }, + { + "name": "Spider Crawl", + "exerciseId": "spider_crawl", + "sets": 4, + "reps": 20, + "equipment": "Bodyweight" + }, + { + "name": "Hanging Leg Raise", + "exerciseId": "hanging-leg-raise", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "FULL BODY B", + "tag": "Foundation", + "exercises": [ + { + "name": "Deadlift", + "exerciseId": "deadlift", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 2, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "CONDITIONING", + "tag": "Engine", + "exercises": [ + { + "name": "Air Bike", + "exerciseId": "air_bike", + "sets": 6, + "reps": 20, + "equipment": "Conditioning" + }, + { + "name": "Reverse Crunch", + "exerciseId": "reverse_crunch", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 4, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Running", + "exerciseId": "running", + "sets": 4, + "reps": 400, + "equipment": "Conditioning" + }, + { + "name": "Spider Crawl", + "exerciseId": "spider_crawl", + "sets": 4, + "reps": 20, + "equipment": "Bodyweight" + }, + { + "name": "Hanging Leg Raise", + "exerciseId": "hanging-leg-raise", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "UPPER B", + "tag": "Upper split", + "exercises": [ + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + } + ] + } + ] + }, + { + "id": "interference_aware_recomp", + "name": "Interference-Aware Fat Loss / Recomp", + "category": "FAT_LOSS_CONDITIONING", + "description": "Recovery-aware lifting and conditioning split.", + "goal": "Recomp without crushing lower-body recovery", + "experienceLevel": "Intermediate", + "daysPerWeek": 5, + "equipment": [ + "Barbell", + "Dumbbell", + "Conditioning", + "Bodyweight", + "Cable", + "Machine" + ], + "isFeatured": true, + "sortOrder": 915, + "blueprint": "INTERFERENCE_AWARE_RECOMP_5", + "goalBias": "fat_loss", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "moderate_high", + "adherenceFloor": 68, + "specializationType": null, + "progressionModel": "double_progression_conservative", + "deloadProtocol": "every_5_7_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": 6, + "guardrailNotes": "Separate hard conditioning from heavy lower sessions when possible and scale intervals by recovery.", + "days": [ + { + "name": "UPPER A", + "tag": "Upper split", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 4, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Pull-Up", + "exerciseId": "pull-up", + "sets": 3, + "reps": 8, + "equipment": "Bodyweight" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + } + ] + }, + { + "name": "CONDITIONING", + "tag": "Engine", + "exercises": [ + { + "name": "Air Bike", + "exerciseId": "air_bike", + "sets": 6, + "reps": 20, + "equipment": "Conditioning" + }, + { + "name": "Reverse Crunch", + "exerciseId": "reverse_crunch", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 4, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Running", + "exerciseId": "running", + "sets": 4, + "reps": 400, + "equipment": "Conditioning" + }, + { + "name": "Spider Crawl", + "exerciseId": "spider_crawl", + "sets": 4, + "reps": 20, + "equipment": "Bodyweight" + }, + { + "name": "Hanging Leg Raise", + "exerciseId": "hanging-leg-raise", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "LOWER B", + "tag": "Lower split", + "exercises": [ + { + "name": "Hack Squat", + "exerciseId": "hack-squat", + "sets": 4, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Leg Extension", + "exerciseId": "leg-extension", + "sets": 3, + "reps": 15, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 4, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + } + ] + }, + { + "name": "UPPER B", + "tag": "Upper split", + "exercises": [ + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "CONDITIONING", + "tag": "Engine", + "exercises": [ + { + "name": "Air Bike", + "exerciseId": "air_bike", + "sets": 6, + "reps": 20, + "equipment": "Conditioning" + }, + { + "name": "Reverse Crunch", + "exerciseId": "reverse_crunch", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 4, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Running", + "exerciseId": "running", + "sets": 4, + "reps": 400, + "equipment": "Conditioning" + }, + { + "name": "Spider Crawl", + "exerciseId": "spider_crawl", + "sets": 4, + "reps": 20, + "equipment": "Bodyweight" + }, + { + "name": "Hanging Leg Raise", + "exerciseId": "hanging-leg-raise", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + } + ] + } + ] + }, + { + "id": "lift_3day_cardio_2day", + "name": "3-Day Lift + 2-Day Cardio Plan", + "category": "FAT_LOSS_CONDITIONING", + "description": "Simple split with cardio built in.", + "goal": "Improve body composition", + "experienceLevel": "All Levels", + "daysPerWeek": 5, + "equipment": [ + "Barbell", + "Conditioning", + "Bodyweight", + "Cable", + "Dumbbell" + ], + "isFeatured": true, + "sortOrder": 920, + "blueprint": "LIFT_3_CARDIO_2", + "goalBias": "fat_loss", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "moderate_high", + "adherenceFloor": 68, + "specializationType": null, + "progressionModel": "double_progression_conservative", + "deloadProtocol": "every_5_7_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": 6, + "guardrailNotes": "Separate hard conditioning from heavy lower sessions when possible and scale intervals by recovery.", + "days": [ + { + "name": "FULL BODY A", + "tag": "Foundation", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 3, + "reps": 45, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "CONDITIONING", + "tag": "Engine", + "exercises": [ + { + "name": "Air Bike", + "exerciseId": "air_bike", + "sets": 6, + "reps": 20, + "equipment": "Conditioning" + }, + { + "name": "Reverse Crunch", + "exerciseId": "reverse_crunch", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 4, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Running", + "exerciseId": "running", + "sets": 4, + "reps": 400, + "equipment": "Conditioning" + }, + { + "name": "Spider Crawl", + "exerciseId": "spider_crawl", + "sets": 4, + "reps": 20, + "equipment": "Bodyweight" + }, + { + "name": "Hanging Leg Raise", + "exerciseId": "hanging-leg-raise", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "FULL BODY B", + "tag": "Foundation", + "exercises": [ + { + "name": "Deadlift", + "exerciseId": "deadlift", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 8, + "equipment": "Barbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 2, + "reps": 15, + "equipment": "Dumbbell" + } + ] + }, + { + "name": "CONDITIONING", + "tag": "Engine", + "exercises": [ + { + "name": "Air Bike", + "exerciseId": "air_bike", + "sets": 6, + "reps": 20, + "equipment": "Conditioning" + }, + { + "name": "Reverse Crunch", + "exerciseId": "reverse_crunch", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 4, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Running", + "exerciseId": "running", + "sets": 4, + "reps": 400, + "equipment": "Conditioning" + }, + { + "name": "Spider Crawl", + "exerciseId": "spider_crawl", + "sets": 4, + "reps": 20, + "equipment": "Bodyweight" + }, + { + "name": "Hanging Leg Raise", + "exerciseId": "hanging-leg-raise", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "FULL BODY C", + "tag": "Foundation", + "exercises": [ + { + "name": "Front Squat", + "exerciseId": "front-squat", + "sets": 3, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Seated Cable Row", + "exerciseId": "seated-cable-row", + "sets": 3, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 2, + "reps": 12, + "equipment": "Cable" + } + ] + } + ] + }, + { + "id": "athletic_conditioning_program", + "name": "Athletic Conditioning Program", + "category": "FAT_LOSS_CONDITIONING", + "description": "Athletic engine + strength emphasis.", + "goal": "Build work capacity and durability", + "experienceLevel": "Intermediate", + "daysPerWeek": 5, + "equipment": [ + "Barbell", + "Conditioning", + "Bodyweight", + "Cable", + "Dumbbell", + "Machine" + ], + "isFeatured": false, + "sortOrder": 930, + "blueprint": "ATHLETIC_CONDITIONING_5", + "goalBias": "fat_loss", + "splitType": "specialized_split", + "minimumEquipment": "barbell", + "sessionLengthTarget": 90, + "recoveryDemand": "moderate_high", + "adherenceFloor": 68, + "specializationType": null, + "progressionModel": "double_progression_conservative", + "deloadProtocol": "every_5_7_weeks_or_2_consecutive_underperformances", + "effortTarget": "RIR 1-3 on working sets, avoid failure on compounds", + "blockDurationWeeks": 6, + "guardrailNotes": "Separate hard conditioning from heavy lower sessions when possible and scale intervals by recovery.", + "days": [ + { + "name": "UPPER POWER", + "tag": "Strength", + "exercises": [ + { + "name": "Barbell Bench Press", + "exerciseId": "barbell-bench-press", + "sets": 4, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Weighted Pull-Up", + "exerciseId": "weighted-pull-up", + "sets": 4, + "reps": 6, + "equipment": "Bodyweight" + }, + { + "name": "Overhead Press", + "exerciseId": "overhead-press", + "sets": 3, + "reps": 5, + "equipment": "Barbell" + }, + { + "name": "Barbell Row", + "exerciseId": "barbell-row", + "sets": 3, + "reps": 6, + "equipment": "Barbell" + }, + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 3, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 3, + "reps": 10, + "equipment": "Cable" + } + ] + }, + { + "name": "CONDITIONING", + "tag": "Engine", + "exercises": [ + { + "name": "Air Bike", + "exerciseId": "air_bike", + "sets": 6, + "reps": 20, + "equipment": "Conditioning" + }, + { + "name": "Reverse Crunch", + "exerciseId": "reverse_crunch", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 4, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Running", + "exerciseId": "running", + "sets": 4, + "reps": 400, + "equipment": "Conditioning" + }, + { + "name": "Spider Crawl", + "exerciseId": "spider_crawl", + "sets": 4, + "reps": 20, + "equipment": "Bodyweight" + }, + { + "name": "Hanging Leg Raise", + "exerciseId": "hanging-leg-raise", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + } + ] + }, + { + "name": "LOWER POWER", + "tag": "Strength", + "exercises": [ + { + "name": "Barbell Squat", + "exerciseId": "barbell-squat", + "sets": 4, + "reps": 4, + "equipment": "Barbell" + }, + { + "name": "Deadlift", + "exerciseId": "deadlift", + "sets": 2, + "reps": 3, + "equipment": "Barbell" + }, + { + "name": "Leg Curl", + "exerciseId": "leg-curl", + "sets": 3, + "reps": 10, + "equipment": "Machine" + }, + { + "name": "Standing Calf Raise", + "exerciseId": "standing-calf-raise", + "sets": 3, + "reps": 12, + "equipment": "Machine" + }, + { + "name": "Walking Lunge", + "exerciseId": "walking-lunge", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Hip Thrust", + "exerciseId": "hip-thrust", + "sets": 3, + "reps": 10, + "equipment": "Barbell" + } + ] + }, + { + "name": "UPPER B", + "tag": "Upper split", + "exercises": [ + { + "name": "Incline Dumbbell Press", + "exerciseId": "incline-dumbbell-press", + "sets": 4, + "reps": 10, + "equipment": "Dumbbell" + }, + { + "name": "Lat Pulldown", + "exerciseId": "lat-pulldown", + "sets": 4, + "reps": 10, + "equipment": "Cable" + }, + { + "name": "Lateral Raise", + "exerciseId": "lateral-raise", + "sets": 3, + "reps": 15, + "equipment": "Dumbbell" + }, + { + "name": "Face Pull", + "exerciseId": "face-pull", + "sets": 3, + "reps": 15, + "equipment": "Cable" + }, + { + "name": "Hammer Curl", + "exerciseId": "hammer-curl", + "sets": 3, + "reps": 12, + "equipment": "Dumbbell" + }, + { + "name": "Tricep Pushdown", + "exerciseId": "tricep-pushdown", + "sets": 3, + "reps": 12, + "equipment": "Cable" + } + ] + }, + { + "name": "CONDITIONING", + "tag": "Engine", + "exercises": [ + { + "name": "Air Bike", + "exerciseId": "air_bike", + "sets": 6, + "reps": 20, + "equipment": "Conditioning" + }, + { + "name": "Reverse Crunch", + "exerciseId": "reverse_crunch", + "sets": 3, + "reps": 15, + "equipment": "Bodyweight" + }, + { + "name": "Plank", + "exerciseId": "plank", + "sets": 4, + "reps": 45, + "equipment": "Bodyweight" + }, + { + "name": "Running", + "exerciseId": "running", + "sets": 4, + "reps": 400, + "equipment": "Conditioning" + }, + { + "name": "Spider Crawl", + "exerciseId": "spider_crawl", + "sets": 4, + "reps": 20, + "equipment": "Bodyweight" + }, + { + "name": "Hanging Leg Raise", + "exerciseId": "hanging-leg-raise", + "sets": 3, + "reps": 12, + "equipment": "Bodyweight" + } + ] + } + ] + } +] \ No newline at end of file diff --git a/app/src/main/assets/ironlog/volume_comparison_dataset.json b/app/src/main/assets/ironlog/volume_comparison_dataset.json new file mode 100644 index 0000000..c491552 --- /dev/null +++ b/app/src/main/assets/ironlog/volume_comparison_dataset.json @@ -0,0 +1,522 @@ +[ + { + "name": "Dumbbell (heavy pair)", + "weightKg": 40, + "category": "objects" + }, + { + "name": "Barbell", + "weightKg": 20, + "category": "objects" + }, + { + "name": "Gym bench", + "weightKg": 35, + "category": "objects" + }, + { + "name": "Washing machine", + "weightKg": 65, + "category": "objects" + }, + { + "name": "Dishwasher", + "weightKg": 50, + "category": "objects" + }, + { + "name": "Office chair", + "weightKg": 15, + "category": "objects" + }, + { + "name": "Gaming PC", + "weightKg": 10, + "category": "objects" + }, + { + "name": "Backpack (loaded)", + "weightKg": 12, + "category": "objects" + }, + { + "name": "Suitcase (full)", + "weightKg": 25, + "category": "objects" + }, + { + "name": "Microwave", + "weightKg": 19, + "category": "objects" + }, + { + "name": "Vacuum cleaner", + "weightKg": 13, + "category": "objects" + }, + { + "name": "Electric guitar", + "weightKg": 5, + "category": "objects" + }, + { + "name": "Dog (medium)", + "weightKg": 20, + "category": "animals" + }, + { + "name": "Child (5-7 yrs)", + "weightKg": 20, + "category": "objects" + }, + { + "name": "Adult human", + "weightKg": 70, + "category": "objects" + }, + { + "name": "Bicycle", + "weightKg": 12, + "category": "objects" + }, + { + "name": "Road bike", + "weightKg": 8, + "category": "objects" + }, + { + "name": "Mattress", + "weightKg": 30, + "category": "objects" + }, + { + "name": "Sofa (2-seater)", + "weightKg": 45, + "category": "objects" + }, + { + "name": "Refrigerator", + "weightKg": 100, + "category": "objects" + }, + { + "name": "Vending machine", + "weightKg": 300, + "category": "objects" + }, + { + "name": "Motorcycle", + "weightKg": 180, + "category": "vehicles" + }, + { + "name": "Horse", + "weightKg": 500, + "category": "animals" + }, + { + "name": "Cow", + "weightKg": 700, + "category": "animals" + }, + { + "name": "Small piano (upright)", + "weightKg": 300, + "category": "objects" + }, + { + "name": "Grand piano", + "weightKg": 500, + "category": "objects" + }, + { + "name": "Smart car", + "weightKg": 900, + "category": "vehicles" + }, + { + "name": "Golf cart", + "weightKg": 400, + "category": "vehicles" + }, + { + "name": "Industrial fridge", + "weightKg": 250, + "category": "industrial" + }, + { + "name": "Jet ski", + "weightKg": 350, + "category": "vehicles" + }, + { + "name": "ATV", + "weightKg": 300, + "category": "vehicles" + }, + { + "name": "Large safe", + "weightKg": 400, + "category": "industrial" + }, + { + "name": "Industrial generator", + "weightKg": 600, + "category": "industrial" + }, + { + "name": "Bear (adult)", + "weightKg": 300, + "category": "animals" + }, + { + "name": "Lion", + "weightKg": 190, + "category": "animals" + }, + { + "name": "Tiger", + "weightKg": 220, + "category": "animals" + }, + { + "name": "Server rack", + "weightKg": 250, + "category": "industrial" + }, + { + "name": "Concrete slab (1m3 section)", + "weightKg": 500, + "category": "industrial" + }, + { + "name": "Brick pallet", + "weightKg": 800, + "category": "industrial" + }, + { + "name": "Large desk setup", + "weightKg": 120, + "category": "objects" + }, + { + "name": "Small car", + "weightKg": 1300, + "category": "vehicles" + }, + { + "name": "Sedan", + "weightKg": 1500, + "category": "vehicles" + }, + { + "name": "SUV", + "weightKg": 2000, + "category": "vehicles" + }, + { + "name": "Pickup truck", + "weightKg": 2500, + "category": "vehicles" + }, + { + "name": "Van", + "weightKg": 3000, + "category": "vehicles" + }, + { + "name": "Elephant (small)", + "weightKg": 3000, + "category": "animals" + }, + { + "name": "Elephant (average)", + "weightKg": 5000, + "category": "animals" + }, + { + "name": "Rhino", + "weightKg": 2300, + "category": "animals" + }, + { + "name": "Hippopotamus", + "weightKg": 3000, + "category": "animals" + }, + { + "name": "Small boat", + "weightKg": 2000, + "category": "vehicles" + }, + { + "name": "Forklift", + "weightKg": 3000, + "category": "industrial" + }, + { + "name": "Shipping pallet load", + "weightKg": 1500, + "category": "industrial" + }, + { + "name": "Tractor", + "weightKg": 4000, + "category": "vehicles" + }, + { + "name": "Large tree trunk", + "weightKg": 2000, + "category": "industrial" + }, + { + "name": "Mini excavator", + "weightKg": 3500, + "category": "industrial" + }, + { + "name": "Light aircraft", + "weightKg": 3000, + "category": "vehicles" + }, + { + "name": "Food truck", + "weightKg": 3500, + "category": "vehicles" + }, + { + "name": "Ice cream truck", + "weightKg": 3000, + "category": "vehicles" + }, + { + "name": "Horse trailer", + "weightKg": 2000, + "category": "vehicles" + }, + { + "name": "Delivery truck (empty)", + "weightKg": 4000, + "category": "vehicles" + }, + { + "name": "Bus", + "weightKg": 8000, + "category": "vehicles" + }, + { + "name": "City bus", + "weightKg": 12000, + "category": "vehicles" + }, + { + "name": "School bus", + "weightKg": 8000, + "category": "vehicles" + }, + { + "name": "Fire truck", + "weightKg": 14000, + "category": "vehicles" + }, + { + "name": "Garbage truck", + "weightKg": 16000, + "category": "vehicles" + }, + { + "name": "Semi truck (no trailer)", + "weightKg": 7000, + "category": "vehicles" + }, + { + "name": "Bulldozer", + "weightKg": 10000, + "category": "industrial" + }, + { + "name": "Tank", + "weightKg": 60000, + "category": "vehicles" + }, + { + "name": "Train carriage", + "weightKg": 30000, + "category": "vehicles" + }, + { + "name": "Large excavator", + "weightKg": 20000, + "category": "industrial" + }, + { + "name": "Blue whale", + "weightKg": 100000, + "category": "animals" + }, + { + "name": "African elephant (large)", + "weightKg": 6000, + "category": "animals" + }, + { + "name": "Loaded shipping container", + "weightKg": 20000, + "category": "industrial" + }, + { + "name": "Yacht (small luxury)", + "weightKg": 15000, + "category": "vehicles" + }, + { + "name": "Concrete mixer truck", + "weightKg": 12000, + "category": "vehicles" + }, + { + "name": "Mobile crane", + "weightKg": 18000, + "category": "industrial" + }, + { + "name": "Wind turbine blade", + "weightKg": 12000, + "category": "industrial" + }, + { + "name": "Large statue", + "weightKg": 10000, + "category": "industrial" + }, + { + "name": "Steel beam bundle", + "weightKg": 8000, + "category": "industrial" + }, + { + "name": "Industrial press machine", + "weightKg": 15000, + "category": "industrial" + }, + { + "name": "Boeing 737", + "weightKg": 40000, + "category": "vehicles" + }, + { + "name": "Boeing 747", + "weightKg": 180000, + "category": "vehicles", + "extreme": true + }, + { + "name": "Cargo ship (small)", + "weightKg": 5000000, + "category": "industrial", + "extreme": true + }, + { + "name": "Blue whale (large)", + "weightKg": 150000, + "category": "animals", + "extreme": true + }, + { + "name": "Space shuttle", + "weightKg": 2000000, + "category": "vehicles", + "extreme": true + }, + { + "name": "Eiffel Tower (partial structure)", + "weightKg": 7300000, + "category": "industrial", + "extreme": true + }, + { + "name": "Oil tanker", + "weightKg": 300000000, + "category": "vehicles", + "extreme": true + }, + { + "name": "Cruise ship", + "weightKg": 100000000, + "category": "vehicles", + "extreme": true + }, + { + "name": "Locomotive engine", + "weightKg": 200000, + "category": "vehicles", + "extreme": true + }, + { + "name": "Rocket (Falcon 9)", + "weightKg": 550000, + "category": "vehicles", + "extreme": true + }, + { + "name": "Saturn V rocket", + "weightKg": 3000000, + "category": "vehicles", + "extreme": true + }, + { + "name": "Aircraft carrier", + "weightKg": 100000000, + "category": "vehicles", + "extreme": true + }, + { + "name": "Stadium structure", + "weightKg": 50000000, + "category": "industrial", + "extreme": true + }, + { + "name": "Skyscraper section", + "weightKg": 10000000, + "category": "industrial", + "extreme": true + }, + { + "name": "Offshore oil rig", + "weightKg": 50000000, + "category": "industrial", + "extreme": true + }, + { + "name": "Suspension bridge section", + "weightKg": 20000000, + "category": "industrial", + "extreme": true + }, + { + "name": "Mountain (small mass estimate segment)", + "weightKg": 1000000000, + "category": "industrial", + "extreme": true + }, + { + "name": "Iceberg (small)", + "weightKg": 10000000, + "category": "industrial", + "extreme": true + }, + { + "name": "Glacier section", + "weightKg": 1000000000, + "category": "industrial", + "extreme": true + }, + { + "name": "Planet-scale (Earth chunk metaphor)", + "weightKg": 0, + "category": "industrial", + "extreme": true, + "disabled": true + } +] \ No newline at end of file diff --git a/app/src/main/java/com/ironlog/app/HealthConnectRationaleActivity.kt b/app/src/main/java/com/ironlog/app/HealthConnectRationaleActivity.kt new file mode 100644 index 0000000..8a5378b --- /dev/null +++ b/app/src/main/java/com/ironlog/app/HealthConnectRationaleActivity.kt @@ -0,0 +1,65 @@ +package com.ironlog.app + +import android.app.Activity +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.view.Gravity +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView + +class HealthConnectRationaleActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val density = resources.displayMetrics.density + fun dp(value: Int): Int = (value * density).toInt() + + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(28), dp(28), dp(28), dp(28)) + setBackgroundColor(Color.rgb(14, 14, 14)) + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + val title = TextView(this).apply { + text = "Ironlog + Health Connect" + setTextColor(Color.WHITE) + textSize = 28f + typeface = android.graphics.Typeface.DEFAULT_BOLD + } + + val body = TextView(this).apply { + text = "Ironlog uses Health Connect only to improve recovery, readiness, body-weight sync, and workout history sync. Your data stays under Android Health Connect permission controls, and you can revoke access at any time." + setTextColor(Color.rgb(190, 190, 190)) + textSize = 17f + setLineSpacing(dp(4).toFloat(), 1f) + setPadding(0, dp(18), 0, dp(26)) + } + + val privacy = Button(this).apply { + text = "Open Privacy Policy" + setOnClickListener { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://ironlogpro.app/privacy"))) + } + } + + val done = Button(this).apply { + text = "Done" + setOnClickListener { finish() } + } + + root.addView(title) + root.addView(body) + root.addView(privacy) + root.addView(done) + setContentView(root) + } +} diff --git a/app/src/main/java/com/ironlog/app/IronLogApplication.kt b/app/src/main/java/com/ironlog/app/IronLogApplication.kt new file mode 100644 index 0000000..ccded27 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/IronLogApplication.kt @@ -0,0 +1,58 @@ +package com.ironlog.app + +import android.app.Application +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.data.objectbox.WorkoutImportProvenanceMigration +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.services.BackupScheduler +import com.ironlog.app.services.ReminderScheduler +import com.ironlog.app.widget.WidgetUpdateWorker +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import timber.log.Timber + +class IronLogApplication : Application() { + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onCreate() { + super.onCreate() + if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) + ObjectBox.init(this) + appScope.launch { + WorkoutImportProvenanceMigration.run() + WidgetUpdateWorker.enqueuePeriodic(this@IronLogApplication) + } + scheduleAutoBackupIfEnabled() + } + + private fun scheduleAutoBackupIfEnabled() { + appScope.launch { + runCatching { + val repo = SettingsRepository() + val autoBackupEnabled = repo.getBoolean("auto_backup_enabled", false) + if (autoBackupEnabled) { + val hour = repo.getString("auto_backup_hour")?.toIntOrNull() ?: 3 + val minute = repo.getString("auto_backup_minute")?.toIntOrNull() ?: 0 + BackupScheduler.scheduleDaily(this@IronLogApplication, hour, minute) + } + val notificationsEnabled = repo.getBoolean("notifications_enabled", false) + if (notificationsEnabled) { + val reminderMinutes = repo.getSettingNumber("dailyReminderTimeMinutes") + ?.toInt() + ?.takeIf { it in 0 until 24 * 60 } + ?: (8 * 60) + ReminderScheduler.scheduleDailyReminder( + this@IronLogApplication, + reminderMinutes / 60, + reminderMinutes % 60, + ) + } else { + ReminderScheduler.cancelDailyReminder(this@IronLogApplication) + } + } + } + } + +} diff --git a/app/src/main/java/com/ironlog/app/MainActivity.kt b/app/src/main/java/com/ironlog/app/MainActivity.kt new file mode 100644 index 0000000..233daf6 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/MainActivity.kt @@ -0,0 +1,139 @@ +package com.ironlog.app + +import android.Manifest +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build.VERSION.SDK_INT +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.services.NotificationActionRouter +import com.ironlog.app.services.PendingWorkoutNotificationBridge +import com.ironlog.app.ui.IronLogApp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Native Android entry point equivalent to index.js + App.js. + * React Native's AppRegistry is replaced by Activity startup, and the headless + * Notifee task is represented by NotificationActionReceiver/BackupWorker stubs. + */ +class MainActivity : ComponentActivity() { + + // Android 13+ requires runtime POST_NOTIFICATIONS approval + private val requestNotifPermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* granted or denied — UX continues either way */ } + + override fun onCreate(savedInstanceState: Bundle?) { + // Force fully transparent nav bar on ALL navigation modes (gesture AND 3-button). + // The default SystemBarStyle.auto() applies a translucent scrim on 3-button nav — + // that scrim is the "chin." dark(transparent) skips it entirely. + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), + ) + super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + requestMaxRefreshRate() + handleIntentRouting(intent) + setContent { IronLogApp() } + + // Request notification permission on first launch (Android 13+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + requestNotifPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } + + override fun onResume() { + super.onResume() + lifecycleScope.launch { + val pending = PendingWorkoutNotificationBridge.consumePendingAction(this@MainActivity) + if (pending?.actionId != null) { + NotificationActionRouter.handleNotificationAction( + actionId = pending.actionId, + payload = pending, + isForeground = true, + ) + } + } + } + + override fun onNewIntent(intent: android.content.Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleIntentRouting(intent) + } + + private fun handleIntentRouting(intent: android.content.Intent?) { + val explicitRoute = intent?.getStringExtra("ironlog_route").orEmpty() + val workoutAction = intent?.getStringExtra("actionId").orEmpty() + val navigateToWorkout = intent?.getBooleanExtra("navigate_to_workout", false) == true + lifecycleScope.launch(Dispatchers.IO) { + val repo = SettingsRepository() + if (workoutAction in setOf( + NotificationActionRouter.Actions.SKIP_REST, + NotificationActionRouter.Actions.ADD_30S, + NotificationActionRouter.Actions.FINISH_WORKOUT, + ) + ) { + repo.setString("pending_workout_action", workoutAction) + } + val route = when { + explicitRoute.isNotBlank() -> explicitRoute + workoutAction.isNotBlank() -> { + val dayId = repo.getString("active_workout_day_id").orEmpty() + if (dayId.isNotBlank()) "ActiveWorkout/${Uri.encode(dayId)}" else "ActiveWorkout" + } + navigateToWorkout -> { + val dayId = repo.getString("active_workout_day_id").orEmpty() + if (dayId.isNotBlank()) "ActiveWorkout/${Uri.encode(dayId)}" else "ActiveWorkout" + } + else -> "" + } + if (route.isNotBlank()) repo.setString("pending_nav_route", route) + } + } + + /** + * Requests the highest refresh rate/mode available on the current display so + * all Compose content and interactions can run at the panel's maximum Hz. + */ + private fun requestMaxRefreshRate() { + val display = if (SDK_INT >= Build.VERSION_CODES.R) { + this.display + } else { + @Suppress("DEPRECATION") + windowManager.defaultDisplay + } ?: return + + val maxMode = display.supportedModes.maxByOrNull { it.refreshRate } ?: return + + // Prefer the physical display mode with highest refresh rate (API 23+). + if (SDK_INT >= Build.VERSION_CODES.M) { + val attrs = window.attributes + attrs.preferredDisplayModeId = maxMode.modeId + attrs.preferredRefreshRate = maxMode.refreshRate + window.attributes = attrs + } else { + @Suppress("DEPRECATION") + window.attributes = window.attributes.apply { + preferredRefreshRate = maxMode.refreshRate + } + } + + } +} diff --git a/app/src/main/java/com/ironlog/app/assets/ForgeFoxAssets.kt b/app/src/main/java/com/ironlog/app/assets/ForgeFoxAssets.kt new file mode 100644 index 0000000..e9e6c0b --- /dev/null +++ b/app/src/main/java/com/ironlog/app/assets/ForgeFoxAssets.kt @@ -0,0 +1,240 @@ +package com.ironlog.app.assets + +import androidx.annotation.DrawableRes +import com.ironlog.app.R + +enum class ForgeFoxExpression( + val id: String, + val displayName: String, + val category: String, + @DrawableRes val drawableRes: Int +) { + Neutral( + id = "forgefox_01_neutral", + displayName = "Neutral", + category = "core", + drawableRes = R.drawable.forgefox_01_neutral + ), + + Smile( + id = "forgefox_02_smile", + displayName = "Smile", + category = "core", + drawableRes = R.drawable.forgefox_02_smile + ), + + BigHappy( + id = "forgefox_03_big_happy", + displayName = "Big Happy", + category = "emotion", + drawableRes = R.drawable.forgefox_03_big_happy + ), + + Excited( + id = "forgefox_04_excited", + displayName = "Excited", + category = "emotion", + drawableRes = R.drawable.forgefox_04_excited + ), + + Laughing( + id = "forgefox_05_laughing", + displayName = "Laughing", + category = "emotion", + drawableRes = R.drawable.forgefox_05_laughing + ), + + WinkTeasing( + id = "forgefox_06_wink_teasing", + displayName = "Wink / Teasing", + category = "emotion", + drawableRes = R.drawable.forgefox_06_wink_teasing + ), + + Determined( + id = "forgefox_07_determined", + displayName = "Determined", + category = "fitness", + drawableRes = R.drawable.forgefox_07_determined + ), + + Serious( + id = "forgefox_08_serious", + displayName = "Serious", + category = "fitness", + drawableRes = R.drawable.forgefox_08_serious + ), + + AngryCoach( + id = "forgefox_09_angry_coach", + displayName = "Angry Coach", + category = "warning", + drawableRes = R.drawable.forgefox_09_angry_coach + ), + + Disappointed( + id = "forgefox_10_disappointed", + displayName = "Disappointed", + category = "warning", + drawableRes = R.drawable.forgefox_10_disappointed + ), + + Sad( + id = "forgefox_11_sad", + displayName = "Sad", + category = "emotion", + drawableRes = R.drawable.forgefox_11_sad + ), + + Exhausted( + id = "forgefox_12_exhausted", + displayName = "Exhausted", + category = "recovery", + drawableRes = R.drawable.forgefox_12_exhausted + ), + + Sleepy( + id = "forgefox_13_sleepy", + displayName = "Sleepy", + category = "recovery", + drawableRes = R.drawable.forgefox_13_sleepy + ), + + Shocked( + id = "forgefox_14_shocked", + displayName = "Shocked", + category = "warning", + drawableRes = R.drawable.forgefox_14_shocked + ), + + Confused( + id = "forgefox_15_confused", + displayName = "Confused", + category = "warning", + drawableRes = R.drawable.forgefox_15_confused + ), + + Proud( + id = "forgefox_16_proud", + displayName = "Proud", + category = "reward", + drawableRes = R.drawable.forgefox_16_proud + ), + + Flexing( + id = "forgefox_17_flexing", + displayName = "Flexing", + category = "fitness", + drawableRes = R.drawable.forgefox_17_flexing + ), + + FistPump( + id = "forgefox_18_fist_pump", + displayName = "Fist Pump", + category = "reward", + drawableRes = R.drawable.forgefox_18_fist_pump + ), + + PointingForward( + id = "forgefox_19_pointing_forward", + displayName = "Pointing Forward", + category = "widget", + drawableRes = R.drawable.forgefox_19_pointing_forward + ), + + Clipboard( + id = "forgefox_20_clipboard", + displayName = "Clipboard", + category = "widget", + drawableRes = R.drawable.forgefox_20_clipboard + ), + + WaterBottle( + id = "forgefox_21_water_bottle", + displayName = "Water Bottle", + category = "widget", + drawableRes = R.drawable.forgefox_21_water_bottle + ), + + Dumbbell( + id = "forgefox_22_dumbbell", + displayName = "Dumbbell", + category = "fitness", + drawableRes = R.drawable.forgefox_22_dumbbell + ), + + PullUp( + id = "forgefox_23_pull_up", + displayName = "Pull Up", + category = "fitness", + drawableRes = R.drawable.forgefox_23_pull_up + ), + + TiredTowel( + id = "forgefox_24_tired_towel", + displayName = "Tired Towel", + category = "recovery", + drawableRes = R.drawable.forgefox_24_tired_towel + ), + + TrophyMedal( + id = "forgefox_25_trophy_medal", + displayName = "Trophy Medal", + category = "reward", + drawableRes = R.drawable.forgefox_25_trophy_medal + ), + + StreakFire( + id = "forgefox_26_streak_fire", + displayName = "Streak Fire", + category = "reward", + drawableRes = R.drawable.forgefox_26_streak_fire + ), + + RepairStreak( + id = "forgefox_27_repair_streak", + displayName = "Repair Streak", + category = "recovery", + drawableRes = R.drawable.forgefox_27_repair_streak + ), + + RestBlanket( + id = "forgefox_28_rest_blanket", + displayName = "Rest Blanket", + category = "recovery", + drawableRes = R.drawable.forgefox_28_rest_blanket + ), + + CheckingWatch( + id = "forgefox_29_checking_watch", + displayName = "Checking Watch", + category = "warning", + drawableRes = R.drawable.forgefox_29_checking_watch + ), + + CoachArmsCrossed( + id = "forgefox_30_coach_arms_crossed", + displayName = "Coach Arms Crossed", + category = "widget", + drawableRes = R.drawable.forgefox_30_coach_arms_crossed + ), + + SupportiveFailure( + id = "forgefox_31_supportive_failure", + displayName = "Supportive Failure", + category = "recovery", + drawableRes = R.drawable.forgefox_31_supportive_failure + ), + + LevelUp( + id = "forgefox_32_level_up", + displayName = "Level Up", + category = "reward", + drawableRes = R.drawable.forgefox_32_level_up + ); + + companion object { + fun fromId(id: String): ForgeFoxExpression = + entries.firstOrNull { it.id == id } ?: Neutral + } +} diff --git a/app/src/main/java/com/ironlog/app/data/health/BiometricSnapshot.kt b/app/src/main/java/com/ironlog/app/data/health/BiometricSnapshot.kt new file mode 100644 index 0000000..f3984c2 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/health/BiometricSnapshot.kt @@ -0,0 +1,18 @@ +// app/src/main/java/com/ironlog/app/data/health/BiometricSnapshot.kt +package com.ironlog.app.data.health + +/** + * Latest biometric data read from Health Connect. + * All values are nullable — null means no recent data available. + * + * @param sleepHours Total sleep hours from last night (Health Connect SleepSessionRecord). + * @param restingHrBpm Resting heart rate in bpm (Health Connect RestingHeartRateRecord). + * @param hrvRmssd Heart Rate Variability RMSSD in ms (Health Connect HRVRecord). + * @param weightKg Latest body weight in kg (Health Connect WeightRecord). + */ +data class BiometricSnapshot( + val sleepHours: Double? = null, + val restingHrBpm: Long? = null, + val hrvRmssd: Double? = null, + val weightKg: Double? = null, +) diff --git a/app/src/main/java/com/ironlog/app/data/health/HealthConnectRepository.kt b/app/src/main/java/com/ironlog/app/data/health/HealthConnectRepository.kt new file mode 100644 index 0000000..f8a2452 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/health/HealthConnectRepository.kt @@ -0,0 +1,156 @@ +// app/src/main/java/com/ironlog/app/data/health/HealthConnectRepository.kt +package com.ironlog.app.data.health + +import android.content.Context +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord +import androidx.health.connect.client.records.RestingHeartRateRecord +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.WeightRecord +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.request.ReadRecordsRequest +import androidx.health.connect.client.time.TimeRangeFilter +import androidx.health.connect.client.units.Mass +import com.ironlog.app.ui.model.HistoryEntry +import timber.log.Timber +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * Wraps the Health Connect client for IronLog read/write operations. + * + * All public methods are suspend functions and must be called from a coroutine. + * Callers should check [isAvailable] before calling any other method. + */ +class HealthConnectRepository(private val context: Context) { + + private val client: HealthConnectClient? by lazy { + runCatching { HealthConnectClient.getOrCreate(context) } + .onFailure { Timber.e(it, "HealthConnect client init failed") } + .getOrNull() + } + + /** Returns true if Health Connect is installed and available on this device. */ + fun isAvailable(): Boolean = + HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE + + /** The permission set IronLog requests from Health Connect. */ + val writePermissions: Set = setOf( + HealthPermission.getWritePermission(ExerciseSessionRecord::class), + HealthPermission.getWritePermission(WeightRecord::class), + ) + + val readPermissions: Set = setOf( + HealthPermission.getReadPermission(SleepSessionRecord::class), + HealthPermission.getReadPermission(RestingHeartRateRecord::class), + HealthPermission.getReadPermission(HeartRateVariabilityRmssdRecord::class), + ) + + val requiredPermissions: Set = writePermissions + readPermissions + + /** Returns which of [requiredPermissions] have been granted. */ + suspend fun grantedPermissions(): Set { + val c = client ?: return emptySet() + return c.permissionController.getGrantedPermissions() + } + + /** Returns true if all required permissions are granted. */ + suspend fun hasAllPermissions(): Boolean = + grantedPermissions().containsAll(requiredPermissions) + + // ── Write ───────────────────────────────────────────────────────────── + + /** + * Write a completed workout session from a [HistoryEntry] to Health Connect. + * No-op if Health Connect is unavailable or permissions are missing. + */ + suspend fun writeWorkoutSession(entry: HistoryEntry) { + val c = client ?: return + runCatching { + val date = LocalDate.parse(entry.date.take(10)) + val zone = ZoneId.systemDefault() + val start = date.atStartOfDay(zone).toInstant() + val end = start.plusSeconds(entry.duration.toLong().coerceAtLeast(60)) + + val record = ExerciseSessionRecord( + startTime = start, + startZoneOffset = zone.rules.getOffset(start), + endTime = end, + endZoneOffset = zone.rules.getOffset(end), + exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, + title = entry.name, + metadata = Metadata(), + ) + c.insertRecords(listOf(record)) + }.onFailure { Timber.e(it, "HealthConnect: writeWorkoutSession failed") } + } + + /** + * Write the user's body weight to Health Connect. + * @param weightKg Weight in kilograms. + */ + suspend fun writeWeight(weightKg: Double) { + val c = client ?: return + runCatching { + val now = Instant.now() + val zone = ZoneId.systemDefault() + val record = WeightRecord( + time = now, + zoneOffset = zone.rules.getOffset(now), + weight = Mass.kilograms(weightKg), + metadata = Metadata(), + ) + c.insertRecords(listOf(record)) + }.onFailure { Timber.e(it, "HealthConnect: writeWeight failed") } + } + + // ── Read ────────────────────────────────────────────────────────────── + + /** + * Read a [BiometricSnapshot] for the last 36 hours. + * Returns a snapshot with null fields where data is unavailable. + */ + suspend fun readBiometricSnapshot(): BiometricSnapshot { + val c = client ?: return BiometricSnapshot() + + val now = Instant.now() + val cutoff = now.minus(Duration.ofHours(36)) + val timeRange = TimeRangeFilter.between(cutoff, now) + + val sleepHours: Double? = runCatching { + val result = c.readRecords( + ReadRecordsRequest(SleepSessionRecord::class, timeRange) + ) + result.records.lastOrNull()?.let { session -> + Duration.between(session.startTime, session.endTime).toMinutes() / 60.0 + } + }.onFailure { Timber.w(it, "HealthConnect: readBiometricSnapshot sleepHours failed") } + .getOrNull() + + val restingHr: Long? = runCatching { + val result = c.readRecords( + ReadRecordsRequest(RestingHeartRateRecord::class, timeRange) + ) + result.records.lastOrNull()?.beatsPerMinute + }.onFailure { Timber.w(it, "HealthConnect: readBiometricSnapshot restingHr failed") } + .getOrNull() + + val hrv: Double? = runCatching { + val result = c.readRecords( + ReadRecordsRequest(HeartRateVariabilityRmssdRecord::class, timeRange) + ) + result.records.lastOrNull()?.heartRateVariabilityMillis + }.onFailure { Timber.w(it, "HealthConnect: readBiometricSnapshot hrv failed") } + .getOrNull() + + return BiometricSnapshot( + sleepHours = sleepHours, + restingHrBpm = restingHr, + hrvRmssd = hrv, + ) + } +} diff --git a/app/src/main/java/com/ironlog/app/data/model/RepositoryModels.kt b/app/src/main/java/com/ironlog/app/data/model/RepositoryModels.kt new file mode 100644 index 0000000..c2e26b1 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/model/RepositoryModels.kt @@ -0,0 +1,230 @@ +package com.ironlog.app.data.model + +import com.ironlog.app.data.objectbox.ExerciseEntity +import com.ironlog.app.data.objectbox.PlanDayEntity +import com.ironlog.app.data.objectbox.PlanEntity +import com.ironlog.app.data.objectbox.PlanExerciseEntity +import com.ironlog.app.data.objectbox.WorkoutEntity +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity +import com.ironlog.app.data.objectbox.WorkoutSetEntity +import kotlinx.serialization.Serializable + +// Exercise repository + +data class ExerciseFilters( + val primaryMuscle: String? = null, + val equipment: String? = null, + val category: String? = null, + val customOnly: Boolean = false, + val limit: Int? = null, +) + +data class MuscleInput( + val muscle: String? = null, + val role: String? = null, + val contribution_fraction: Double? = null, +) + +data class CreateExerciseInput( + val name: String? = null, + val primaryMuscle: String? = null, + val equipment: String? = null, + val category: String? = null, + val notes: String? = null, + val muscles: List? = null, + val trackingType: String? = null, + val movementPattern: String? = null, + val difficulty: String? = null, + val isBodyweight: Boolean? = null, + val secondaryMuscles: List? = null, +) + +data class UpdateExerciseInput( + val name: String? = null, + val primaryMuscle: String? = null, + val equipment: String? = null, + val category: String? = null, + val notes: String? = null, +) + +data class LegacyExerciseShape( + val id: String, + val exerciseId: String, + val name: String, + val primaryMuscles: List, + val primaryMuscle: String?, + val secondaryMuscles: List, + val equipment: String, + val category: String, + val trackingType: String, + val isCustom: Boolean, + val aliases: List, + val isBodyweight: Boolean, + val movementPattern: String?, + val difficulty: String?, + val apparatus: String?, + val equipmentDetail: String?, + val sourceTags: List, + val notes: String, +) + +// Plan repository + +data class PlanInput( + val name: String? = null, + val goal: String? = null, + val description: String? = null, + val isActive: Boolean? = null, +) + +data class PlanDayInput( + val name: String? = null, + val color: String? = null, + val orderIndex: Int? = null, +) + +data class PlanExerciseInput( + val exerciseId: String? = null, + val name: String? = null, + val orderIndex: Int? = null, + val sets: Int? = null, + val reps: String? = null, + val restSeconds: Int? = null, + val supersetGroup: String? = null, + val isWarmup: Boolean? = null, + val notes: String? = null, +) + +data class PlanBundle( + val plan: PlanEntity, + val days: List, + val activeDayId: String?, +) + +data class PlanSnapshot( + val plan: PlanEntity, + val days: List, + val exercises: List, +) + +data class WorkoutPerformedSet( + val type: String? = null, + val reps: Double? = null, + val restSeconds: Int? = null, + val rest: Int? = null, + val isWarmup: Boolean? = null, +) + +data class WorkoutPerformedExercise( + val exerciseId: String? = null, + val name: String? = null, + val sets: List? = null, + val prescribedReps: Any? = null, +) + +data class FullPlanObject( + val name: String? = null, + val goal: String? = null, + val description: String? = null, + val days: List = emptyList(), +) + +data class FullPlanDay( + val name: String? = null, + val color: String? = null, + val exercises: List = emptyList(), +) + +// Workout repository + +data class SetInput( + val setIndex: Int? = null, + val weight: Double? = null, + val reps: Double? = null, + val rpe: Double? = null, + val rir: Double? = null, + val restSeconds: Int? = null, + val isWarmup: Boolean? = null, + val isDropset: Boolean? = null, + val isAmrap: Boolean? = null, + val isAMRAP: Boolean? = null, + val toFailure: Boolean? = null, + val completedAt: Long? = null, + val type: String? = null, + val rest: Int? = null, +) + +data class WorkoutMetadataInput( + val startedAt: Long? = null, + val durationSeconds: Int? = null, + val rating: Double? = null, + val notes: String? = null, +) + +data class CompletedExerciseInput( + val exerciseId: String? = null, + val name: String? = null, + val primaryMuscles: List? = null, + val primaryMuscle: String? = null, + val equipment: String? = null, + val category: String? = null, + val supersetGroup: String? = null, + val note: String? = null, + val notes: String? = null, + val sets: List = emptyList(), +) + +data class CreateCompletedWorkoutInput( + val name: String? = null, + val startedAt: Long, + val durationSeconds: Int, + val rating: Double? = null, + val notes: String? = null, + val exerciseData: List = emptyList(), +) + +data class WorkoutDetail( + val workout: WorkoutEntity, + val exercises: List, + val sets: List, + val totalVolume: Double, +) + +// Stats repository + +data class PrRecord( + val exerciseId: String, + val exerciseName: String, + val exercisePrGroupId: String, + val weight: Double, + val reps: Double, + val estimated1RM: Double, +) + +// Seed JSON DTOs +@Serializable +data class ExerciseSeedPayload( + val meta: Map = emptyMap(), + val exercises: List = emptyList(), +) + +@Serializable +data class ExerciseSeedEntry( + val id: String? = null, + val name: String? = null, + val primaryMuscle: String? = null, + val primaryMuscles: List? = null, + val secondaryMuscles: List? = null, + val equipment: String? = null, + val equipmentDetail: String? = null, + val apparatus: List? = null, + val trackingType: String? = null, + val category: String? = null, + val isBodyweight: Boolean? = null, + val requiresExternalLoad: Boolean? = null, + val movementPattern: String? = null, + val difficulty: String? = null, + val aliases: List? = null, + val sourceTags: List? = null, + val notes: String? = null, +) diff --git a/app/src/main/java/com/ironlog/app/data/objectbox/AthleteCalibrationEntity.kt b/app/src/main/java/com/ironlog/app/data/objectbox/AthleteCalibrationEntity.kt new file mode 100644 index 0000000..251c9ad --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/objectbox/AthleteCalibrationEntity.kt @@ -0,0 +1,28 @@ +package com.ironlog.app.data.objectbox + +import io.objectbox.annotation.Entity +import io.objectbox.annotation.Id +import io.objectbox.annotation.Unique + +@Entity +data class AthleteCalibrationEntity( + @Id var id: Long = 0, + @Unique var offlineUserId: String = "", + var trainingAgeMonths: Int = 0, + var historicalTrainingDaysPerWeek: Int = 3, + var firstVerifiedSessionAt: Long = 0L, + var weightUnit: String = "kg", + var bodyweightKg: Double? = null, + var goalMode: String = "hypertrophy", + var weeklyGoalDays: Int = 4, + var importedHistory: Boolean = false, + var confidence: Double = 0.5, + var updatedAt: Long = 0L, + var hasPastTraining: Boolean = false, + var hasGymAccess: Boolean = true, + var baselinePushups: Int = 0, + var baselinePullups: Int = 0, + var baselineBenchKg: Int = 0, + var baselineLatPulldownKg: Int = 0, + var baselineMileRunSeconds: Int = 0, +) diff --git a/app/src/main/java/com/ironlog/app/data/objectbox/Entities.kt b/app/src/main/java/com/ironlog/app/data/objectbox/Entities.kt new file mode 100644 index 0000000..b5fbc43 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/objectbox/Entities.kt @@ -0,0 +1,246 @@ +package com.ironlog.app.data.objectbox + +import io.objectbox.annotation.Backlink +import io.objectbox.annotation.Entity +import io.objectbox.annotation.Id +import io.objectbox.annotation.Index +import io.objectbox.annotation.Unique +import io.objectbox.annotation.ConflictStrategy +import io.objectbox.relation.ToMany +import io.objectbox.relation.ToOne +import java.util.UUID + +internal fun newUid(): String = UUID.randomUUID().toString() + +/** ObjectBox entity: db_smoke_tests */ +@Entity +class DbSmokeTestEntity { + @Id var objectBoxId: Long = 0 + var label: String = "" + var createdAt: Long = 0L +} + +/** ObjectBox entity: exercises */ +@Entity +class ExerciseEntity { + @Id var objectBoxId: Long = 0 + + /** Stable string id for cross-device and export/import identity. */ + @Unique(onConflict = ConflictStrategy.FAIL) + var uid: String = newUid() + + @Index var name: String = "" + @Index var normalizedName: String = "" + @Index var primaryMuscle: String = "" + @Index var equipment: String = "" + var category: String = "strength" + @Index var isCustom: Boolean = false + var source: String = "" + var notes: String = "" + var createdAt: Long = 0L + var updatedAt: Long = 0L + + /** Optional metadata present in exerciseLibrary.json but not in schema v2. */ + var equipmentDetail: String? = null + var apparatusJson: String? = null + var trackingType: String? = null + var isBodyweight: Boolean = false + var requiresExternalLoad: Boolean = false + var movementPattern: String? = null + var difficulty: String? = null + var aliasesJson: String? = null + var sourceTagsJson: String? = null + var secondaryMusclesJson: String? = null + + @Backlink(to = "exercise") + lateinit var muscles: ToMany +} + +/** ObjectBox entity: exercise_muscles */ +@Entity +class ExerciseMuscleEntity { + @Id var objectBoxId: Long = 0 + @Unique(onConflict = ConflictStrategy.REPLACE) + var uid: String = newUid() + + lateinit var exercise: ToOne + @Index var exerciseUid: String = "" + @Index var muscle: String = "" + var role: String = "secondary" + var contributionFraction: Double = 0.0 + var createdAt: Long = 0L + var updatedAt: Long = 0L +} + +/** ObjectBox entity: plans */ +@Entity +class PlanEntity { + @Id var objectBoxId: Long = 0 + @Unique(onConflict = ConflictStrategy.FAIL) + var uid: String = newUid() + + var name: String = "" + var goal: String = "" + var description: String = "" + @Index var isActive: Boolean = false + var createdAt: Long = 0L + @Index var updatedAt: Long = 0L + + @Backlink(to = "plan") + lateinit var days: ToMany +} + +/** ObjectBox entity: plan_days */ +@Entity +class PlanDayEntity { + @Id var objectBoxId: Long = 0 + @Unique(onConflict = ConflictStrategy.FAIL) + var uid: String = newUid() + + lateinit var plan: ToOne + @Index var planUid: String = "" + var name: String = "" + var color: String = "#FF4500" + @Index var orderIndex: Int = 0 + var createdAt: Long = 0L + var updatedAt: Long = 0L + + @Backlink(to = "planDay") + lateinit var exercises: ToMany +} + +/** ObjectBox entity: plan_exercises */ +@Entity +class PlanExerciseEntity { + @Id var objectBoxId: Long = 0 + @Unique(onConflict = ConflictStrategy.FAIL) + var uid: String = newUid() + + lateinit var planDay: ToOne + @Index var planDayUid: String = "" + lateinit var exercise: ToOne + @Index var exerciseUid: String = "" + @Index var orderIndex: Int = 0 + var sets: Int = 1 + var reps: String = "" + var restSeconds: Int = 0 + var supersetGroup: String = "" + var isWarmup: Boolean = false + var notes: String = "" + var createdAt: Long = 0L + var updatedAt: Long = 0L +} + +/** ObjectBox entity: workouts */ +@Entity +class WorkoutEntity { + @Id var objectBoxId: Long = 0 + @Unique(onConflict = ConflictStrategy.FAIL) + var uid: String = newUid() + + lateinit var plan: ToOne + @Index var planUid: String? = null + lateinit var planDay: ToOne + @Index var planDayUid: String? = null + var name: String = "" + @Index var startedAt: Long = 0L + var completedAt: Long? = null + var durationSeconds: Int = 0 + var rating: Double? = null + var notes: String = "" + @Index var status: String = "active" + /** True when this workout came from an external/legacy import rather than local logging. */ + var imported: Boolean = false + var createdAt: Long = 0L + var updatedAt: Long = 0L + + @Backlink(to = "workout") + lateinit var exercises: ToMany +} + +/** ObjectBox entity: workout_exercises */ +@Entity +class WorkoutExerciseEntity { + @Id var objectBoxId: Long = 0 + @Unique(onConflict = ConflictStrategy.FAIL) + var uid: String = newUid() + + lateinit var workout: ToOne + @Index var workoutUid: String = "" + lateinit var exercise: ToOne + @Index var exerciseUid: String = "" + @Index var orderIndex: Int = 0 + var supersetGroup: String = "" + var notes: String = "" + var createdAt: Long = 0L + var updatedAt: Long = 0L + + @Backlink(to = "workoutExercise") + lateinit var sets: ToMany +} + +/** ObjectBox entity: workout_sets */ +@Entity +class WorkoutSetEntity { + @Id var objectBoxId: Long = 0 + @Unique(onConflict = ConflictStrategy.FAIL) + var uid: String = newUid() + + lateinit var workoutExercise: ToOne + @Index var workoutExerciseUid: String = "" + @Index var setIndex: Int = 1 + var weight: Double = 0.0 + var reps: Double = 0.0 + var rpe: Double? = null + var rir: Double? = null + var restSeconds: Int = 0 + @Index var isWarmup: Boolean = false + var isDropset: Boolean = false + var isAmrap: Boolean = false + var toFailure: Boolean = false + var completedAt: Long? = null + var createdAt: Long = 0L + var updatedAt: Long = 0L +} + +/** ObjectBox entity: body_measurements */ +@Entity +class BodyMeasurementEntity { + @Id var objectBoxId: Long = 0 + @Unique(onConflict = ConflictStrategy.FAIL) + var uid: String = newUid() + @Index var measuredAt: Long = 0L + var bodyweight: Double? = null + var waist: Double? = null + var chest: Double? = null + var arm: Double? = null + var thigh: Double? = null + var notes: String = "" + var createdAt: Long = 0L + var updatedAt: Long = 0L +} + +/** ObjectBox entity: app_settings */ +@Entity +class AppSettingEntity { + @Id var objectBoxId: Long = 0 + @Unique(onConflict = ConflictStrategy.REPLACE) + var key: String = "" + var value: String = "" + var valueType: String = "string" + var updatedAt: Long = 0L +} + +/** ObjectBox entity: progress_photos */ +@Entity +class ProgressPhotoEntity { + @Id var objectBoxId: Long = 0 + @Unique(onConflict = ConflictStrategy.FAIL) + var uid: String = newUid() + var fileUri: String = "" + @Index var takenAt: Long = 0L + var bodyweight: Double? = null + var notes: String = "" + var createdAt: Long = 0L + var updatedAt: Long = 0L +} diff --git a/app/src/main/java/com/ironlog/app/data/objectbox/GamificationProfileEntity.kt b/app/src/main/java/com/ironlog/app/data/objectbox/GamificationProfileEntity.kt new file mode 100644 index 0000000..bee6cfc --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/objectbox/GamificationProfileEntity.kt @@ -0,0 +1,48 @@ +// app/src/main/java/com/ironlog/app/data/entity/GamificationProfileEntity.kt +package com.ironlog.app.data.objectbox + +import io.objectbox.annotation.ConflictStrategy +import io.objectbox.annotation.Entity +import io.objectbox.annotation.Id +import io.objectbox.annotation.Unique + +@Entity +data class GamificationProfileEntity( + @Id var id: Long = 0, + + /** Stable user identifier (UUID string, set once on first launch). */ + @Unique var offlineUserId: String = "", + + /** Total accumulated XP across all time. */ + var totalXp: Long = 0L, + + /** Current level (1-100), derived from totalXp but cached for display. */ + var level: Int = 1, + + /** XP within the current level (0..xpForLevel(level)). */ + var xpInLevel: Long = 0L, + + /** Current Iron Ledger grade label or legacy bridge key. */ + var rank: String = "E", + + /** Active title (unlocked badge name). */ + var activeTitle: String = "Ledger Initiate", + + /** JSON-serialized RpgStats for fast display without recomputing. */ + var statsJson: String = "{}", + + /** XP earned this ISO week (for weekly leaderboard -- stored, not yet used). */ + var weeklyXp: Int = 0, + + /** ISO week key of last weeklyXp reset, e.g. "2026-W21". */ + var weeklyXpResetWeek: String = "", + + /** Current streak in qualifying weeks. */ + var streakWeeks: Int = 0, + + /** JSON map of ISO-week-key -> recovery circuit completions. Field name is legacy ObjectBox schema. */ + var makeupCompletionsJson: String = "{}", + + /** Comma-separated list of unlocked badge IDs. */ + var unlockedBadges: String = "", +) diff --git a/app/src/main/java/com/ironlog/app/data/objectbox/IronLedgerEventEntity.kt b/app/src/main/java/com/ironlog/app/data/objectbox/IronLedgerEventEntity.kt new file mode 100644 index 0000000..9ab48f3 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/objectbox/IronLedgerEventEntity.kt @@ -0,0 +1,22 @@ +package com.ironlog.app.data.objectbox + +import io.objectbox.annotation.Entity +import io.objectbox.annotation.Id +import io.objectbox.annotation.Index +import io.objectbox.annotation.Unique + +@Entity +data class IronLedgerEventEntity( + @Id var id: Long = 0, + @Unique var eventId: String = "", + @Index var sourceType: String = "", + @Index var sourceId: String = "", + @Index var eventKind: String = "", + @Index var occurredAt: Long = 0L, + var xpDelta: Int = 0, + var statDeltasJson: String = "{}", + var trustScore: Double = 1.0, + @Index var fingerprint: String = "", + var metadataJson: String = "{}", + @Index var invalidated: Boolean = false, +) diff --git a/app/src/main/java/com/ironlog/app/data/objectbox/ObjectBox.kt b/app/src/main/java/com/ironlog/app/data/objectbox/ObjectBox.kt new file mode 100644 index 0000000..1a0ecc1 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/objectbox/ObjectBox.kt @@ -0,0 +1,26 @@ +package com.ironlog.app.data.objectbox + +import android.content.Context +import io.objectbox.BoxStore + +/** + * ObjectBox store — singleton gateway to the on-device database. + * Initialised once in IronLogApplication and shared across all repositories. + */ +object ObjectBox { + lateinit var store: BoxStore + private set + + @Synchronized + fun init(context: Context) { + if (::store.isInitialized) return + val appCtx = context.applicationContext + // Never turn an initialization failure into silent data loss. Schema migrations must + // preserve the stable IDs in objectbox-models/default.json; disk/corruption failures + // are surfaced to the caller so recovery can be explicit and backup-aware. + store = MyObjectBox.builder() + .androidContext(appCtx) + .name("ironlog_objectbox") + .build() + } +} diff --git a/app/src/main/java/com/ironlog/app/data/objectbox/WorkoutImportProvenanceMigration.kt b/app/src/main/java/com/ironlog/app/data/objectbox/WorkoutImportProvenanceMigration.kt new file mode 100644 index 0000000..ff4f7d3 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/objectbox/WorkoutImportProvenanceMigration.kt @@ -0,0 +1,110 @@ +package com.ironlog.app.data.objectbox + +import org.json.JSONObject + +/** + * One-time bridge from the legacy account-wide imported-history flag to per-workout + * provenance. Existing databases cannot identify mixed native/imported rows, so the safe + * migration marks the completed history present at upgrade time as imported. Workouts logged + * after the migration keep the entity default (`imported = false`). + */ +object WorkoutImportProvenanceMigration { + private const val MIGRATION_KEY = "workout_import_provenance_v1" + private const val LEGACY_IMPORTED_KEY = "ledger_imported_history" + + fun run() { + val store = ObjectBox.store + normalizeAthleteSingletons() + val settingsBox = store.boxFor(AppSettingEntity::class.java) + val alreadyMigrated = settingsBox.query(AppSettingEntity_.key.equal(MIGRATION_KEY)) + .build().use { it.findFirst() } + ?.value + ?.toBooleanStrictOrNull() == true + if (alreadyMigrated) return + + store.runInTx { + val legacySetting = settingsBox.query(AppSettingEntity_.key.equal(LEGACY_IMPORTED_KEY)) + .build().use { it.findFirst() } + ?.value + ?.toBooleanStrictOrNull() == true + val legacyCalibration = store.boxFor(AthleteCalibrationEntity::class.java) + .query(AthleteCalibrationEntity_.importedHistory.equal(true)) + .build().use { it.count() > 0 } + + if (legacySetting || legacyCalibration) { + val workoutBox = store.boxFor(WorkoutEntity::class.java) + val completed = workoutBox.query(WorkoutEntity_.status.equal("completed")) + .build().use { it.find() } + completed.forEach { it.imported = true } + workoutBox.put(completed) + } + + settingsBox.put(AppSettingEntity().apply { + key = MIGRATION_KEY + value = "true" + valueType = "boolean" + updatedAt = System.currentTimeMillis() + }) + } + } + + private fun normalizeAthleteSingletons() { + val store = ObjectBox.store + store.runInTx { + val profileBox = store.boxFor(GamificationProfileEntity::class.java) + val profiles = profileBox.all + if (profiles.isNotEmpty() && (profiles.size > 1 || profiles.single().offlineUserId != "local")) { + val primary = profiles.maxBy { it.totalXp } + val badges = profiles.flatMap { it.unlockedBadges.split(',') } + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + val mergedCompletions = mergeCompletionMaps(profiles.map { it.makeupCompletionsJson }) + profileBox.remove(profiles) + primary.id = 0 + primary.offlineUserId = "local" + primary.totalXp = profiles.maxOf { it.totalXp } + primary.level = profiles.maxOf { it.level } + primary.weeklyXp = profiles.maxOf { it.weeklyXp } + primary.streakWeeks = profiles.maxOf { it.streakWeeks } + primary.unlockedBadges = badges.joinToString(",") + primary.makeupCompletionsJson = mergedCompletions + profileBox.put(primary) + } + + val calibrationBox = store.boxFor(AthleteCalibrationEntity::class.java) + val calibrations = calibrationBox.all + if (calibrations.isNotEmpty() && (calibrations.size > 1 || calibrations.single().offlineUserId != "local")) { + val latest = calibrations.maxBy { it.updatedAt } + val nonZeroFirstSession = calibrations.map { it.firstVerifiedSessionAt } + .filter { it > 0L } + .minOrNull() ?: 0L + calibrationBox.remove(calibrations) + latest.id = 0 + latest.offlineUserId = "local" + latest.trainingAgeMonths = calibrations.maxOf { it.trainingAgeMonths } + latest.firstVerifiedSessionAt = nonZeroFirstSession + latest.importedHistory = calibrations.any { it.importedHistory } + latest.confidence = calibrations.maxOf { it.confidence } + latest.hasPastTraining = calibrations.any { it.hasPastTraining } + latest.baselinePushups = calibrations.maxOf { it.baselinePushups } + latest.baselinePullups = calibrations.maxOf { it.baselinePullups } + latest.baselineBenchKg = calibrations.maxOf { it.baselineBenchKg } + latest.baselineLatPulldownKg = calibrations.maxOf { it.baselineLatPulldownKg } + latest.baselineMileRunSeconds = calibrations.maxOf { it.baselineMileRunSeconds } + calibrationBox.put(latest) + } + } + } + + private fun mergeCompletionMaps(values: List): String { + val merged = linkedMapOf() + values.forEach { raw -> + val json = runCatching { JSONObject(raw) }.getOrNull() ?: return@forEach + json.keys().forEach { key -> + merged[key] = maxOf(merged[key] ?: 0, json.optInt(key, 0)) + } + } + return JSONObject(merged as Map<*, *>).toString() + } +} diff --git a/app/src/main/java/com/ironlog/app/data/repository/BodyMeasurementRepository.kt b/app/src/main/java/com/ironlog/app/data/repository/BodyMeasurementRepository.kt new file mode 100644 index 0000000..fc98594 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/repository/BodyMeasurementRepository.kt @@ -0,0 +1,107 @@ +package com.ironlog.app.data.repository + +import com.ironlog.app.data.objectbox.BodyMeasurementEntity +import com.ironlog.app.data.objectbox.BodyMeasurementEntity_ +import com.ironlog.app.data.objectbox.ObjectBox +import io.objectbox.Box +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Repository for body measurement entries. + * Reactive updates are emitted as Flow> via ObjectBoxFlow.observeQuery. + */ +data class BodyMeasurementInput( + val measuredAt: Long? = null, + val bodyweight: Double? = null, + val waist: Double? = null, + val chest: Double? = null, + val arm: Double? = null, + val thigh: Double? = null, + val notes: String? = null, +) + +class BodyMeasurementRepository( + private val bodyBox: Box = ObjectBox.store.boxFor(BodyMeasurementEntity::class.java), +) { + fun getBodyMeasurementsFlow() = + bodyBox.query().orderDesc(BodyMeasurementEntity_.measuredAt).build().asFlow() + + suspend fun addBodyMeasurement(input: BodyMeasurementInput = BodyMeasurementInput()): BodyMeasurementEntity = + withContext(Dispatchers.IO) { + input.bodyweight?.let { require(it >= 0.0) { "bodyweight must be >= 0" } } + val now = System.currentTimeMillis() + val row = BodyMeasurementEntity().apply { + measuredAt = input.measuredAt ?: now + bodyweight = input.bodyweight + waist = input.waist + chest = input.chest + arm = input.arm + thigh = input.thigh + notes = input.notes?.takeIf { it.isNotEmpty() } ?: "" + createdAt = now + updatedAt = now + } + bodyBox.put(row) + row + } + + suspend fun updateBodyMeasurement(slug: String, input: BodyMeasurementInput = BodyMeasurementInput()): BodyMeasurementEntity = + withContext(Dispatchers.IO) { + val row = bodyBox.query(BodyMeasurementEntity_.uid.equal(slug)).build().use { it.findFirst() } + ?: error("Body measurement not found: $slug") + input.measuredAt?.let { row.measuredAt = it } + // JS updates nullable values when property is present. Kotlin input cannot represent undefined separately; + // call clearBodyWeight/explicit nullable variants if that distinction is needed during integration. + if (input.bodyweight != null) row.bodyweight = input.bodyweight + if (input.waist != null) row.waist = input.waist + if (input.chest != null) row.chest = input.chest + if (input.arm != null) row.arm = input.arm + if (input.thigh != null) row.thigh = input.thigh + if (input.notes != null) row.notes = input.notes.takeIf { it.isNotEmpty() } ?: "" + row.updatedAt = System.currentTimeMillis() + bodyBox.put(row) + row + } + + suspend fun deleteBodyMeasurement(slug: String) = withContext(Dispatchers.IO) { + val row = bodyBox.query(BodyMeasurementEntity_.uid.equal(slug)).build().use { it.findFirst() } ?: return@withContext + bodyBox.remove(row) + } + + suspend fun getLatestBodyweight(): Double? = withContext(Dispatchers.IO) { + bodyBox.query().orderDesc(BodyMeasurementEntity_.measuredAt).build().use { query -> + query.find().firstOrNull { (it.bodyweight ?: 0.0) > 0.0 }?.bodyweight + } + } + + /** Returns the date of the latest body weight entry as a formatted string (e.g. "May 3"). */ + suspend fun getLatestBodyweightDate(): String? = withContext(Dispatchers.IO) { + bodyBox.query().orderDesc(BodyMeasurementEntity_.measuredAt).build().use { query -> + query.find().firstOrNull { (it.bodyweight ?: 0.0) > 0.0 }?.measuredAt?.let { ms -> + java.time.Instant.ofEpochMilli(ms) + .atZone(java.time.ZoneId.systemDefault()) + .format(java.time.format.DateTimeFormatter.ofPattern("MMM d")) + } + } + } + + /** Returns the delta in kg between the most recent entry and the entry closest to 7 days ago. Null if <2 entries. */ + suspend fun getWeeklyBodyweightDelta(): Double? = withContext(Dispatchers.IO) { + val entries = bodyBox.query().orderDesc(BodyMeasurementEntity_.measuredAt).build().use { q -> + q.find().filter { (it.bodyweight ?: 0.0) > 0.0 } + } + if (entries.size < 2) return@withContext null + val latest = entries.first() + val sevenDaysAgo = latest.measuredAt - 7L * 24 * 60 * 60 * 1000 + // Cap look-back: only consider entries within ±3 days of the 7-days-ago target. + // Without this cap, if the user logs infrequently the delta could compare against + // an entry that is months old, producing a misleading result. + val threeDaysMs = 3L * 24 * 60 * 60 * 1000 + val weekOldEntry = entries.drop(1) + .filter { kotlin.math.abs(it.measuredAt - sevenDaysAgo) <= threeDaysMs } + .minByOrNull { kotlin.math.abs(it.measuredAt - sevenDaysAgo) } + ?: return@withContext null + (latest.bodyweight ?: 0.0) - (weekOldEntry.bodyweight ?: 0.0) + } +} diff --git a/app/src/main/java/com/ironlog/app/data/repository/ExerciseRepository.kt b/app/src/main/java/com/ironlog/app/data/repository/ExerciseRepository.kt new file mode 100644 index 0000000..9fd22a8 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/repository/ExerciseRepository.kt @@ -0,0 +1,272 @@ +package com.ironlog.app.data.repository + +import android.content.Context +import com.ironlog.app.util.ExerciseTrackingTypeNormalizer +import com.ironlog.app.data.model.CreateExerciseInput +import com.ironlog.app.data.model.ExerciseFilters +import com.ironlog.app.data.model.LegacyExerciseShape +import com.ironlog.app.data.model.UpdateExerciseInput +import com.ironlog.app.data.objectbox.ExerciseEntity +import com.ironlog.app.data.objectbox.ExerciseEntity_ +import com.ironlog.app.data.objectbox.ExerciseMuscleEntity +import com.ironlog.app.data.objectbox.ExerciseMuscleEntity_ +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.data.objectbox.PlanExerciseEntity +import com.ironlog.app.data.objectbox.PlanExerciseEntity_ +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity_ +import com.ironlog.app.data.seed.ExerciseSeed +import com.ironlog.app.domain.intelligence.SubstitutionEngine +import com.ironlog.app.util.normalizeExerciseName +import com.ironlog.app.util.normalizeExerciseNameKey +import com.ironlog.app.util.requireNonEmpty +import com.ironlog.app.util.validateContributionFraction +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +class ExerciseRepository(private val context: Context? = null) { + private val exercisesBox get() = ObjectBox.store.boxFor(ExerciseEntity::class.java) + private val musclesBox get() = ObjectBox.store.boxFor(ExerciseMuscleEntity::class.java) + + suspend fun seedExercisesIfNeeded(): ExerciseSeed.SeedResult { + val ctx = context ?: error("Context is required to seed exerciseLibrary.json") + return ExerciseSeed(ctx).seedExercisesIfNeeded() + } + + suspend fun backfillExerciseMusclesIfNeeded(): ExerciseSeed.BackfillResult { + val ctx = context ?: error("Context is required to backfill exercise muscles") + return ExerciseSeed(ctx).backfillExerciseMusclesIfNeeded() + } + + private fun getExercisesFlow(filters: ExerciseFilters = ExerciseFilters()): Flow> = + exercisesBox.query().build().asFlow().map { rows -> filterRows(rows, filters) } + + suspend fun getExercisesSnapshot(filters: ExerciseFilters = ExerciseFilters()): List = + withContext(Dispatchers.IO) { filterRows(exercisesBox.all, filters).map(::mapExerciseRowToLegacyShape) } + + suspend fun getCompactCatalogMarkdown(): String = withContext(Dispatchers.IO) { + val canonical = context?.let { catalogMarkdownFile(it) } + if (canonical != null && canonical.exists()) { + return@withContext canonical.readText() + } + val generated = buildCompactCatalogMarkdown(filterRows(exercisesBox.all, ExerciseFilters()).map(::mapExerciseRowToLegacyShape)) + canonical?.let { + it.parentFile?.mkdirs() + it.writeText(generated) + } + generated + } + + fun searchExercisesFlow(query: String?, filters: ExerciseFilters = ExerciseFilters()): Flow> { + val normalized = normalizeExerciseNameKey(query.orEmpty()) + return kotlinx.coroutines.flow.flow { + getExercisesFlow(filters).collect { rows -> + emit(if (normalized.isBlank()) rows else rows.filter { it.normalizedName.contains(normalized) }) + } + } + } + + suspend fun createCustomExercise(input: CreateExerciseInput): ExerciseEntity = withContext(Dispatchers.IO) { + val name = normalizeExerciseName(input.name) + requireNonEmpty(name, "name") + requireNonEmpty(input.primaryMuscle, "primary_muscle") + requireNonEmpty(input.equipment, "equipment") + requireNonEmpty(input.category, "category") + val normalized = normalizeExerciseNameKey(name) + val duplicate = exercisesBox.query(ExerciseEntity_.normalizedName.equal(normalized)).build().use { it.find() } + if (duplicate.isNotEmpty()) error("Exercise with this name already exists.") + + // Validate every child before the parent is written so a bad contribution cannot leave + // behind a partially-created exercise. + input.muscles.orEmpty().forEach { validateContributionFraction(it.contribution_fraction) } + + val now = System.currentTimeMillis() + val created = ExerciseEntity().apply { + this.name = name + normalizedName = normalized + primaryMuscle = input.primaryMuscle!! + equipment = input.equipment!! + category = input.category!! + isCustom = true + source = "user_custom" + notes = input.notes?.toString() ?: "" + createdAt = now + updatedAt = now + isBodyweight = input.isBodyweight ?: (equipment.lowercase() == "bodyweight") + trackingType = ExerciseTrackingTypeNormalizer.normalize( + name = name, + category = category, + equipment = equipment, + explicitTrackingType = input.trackingType, + ) + movementPattern = input.movementPattern + difficulty = input.difficulty + secondaryMusclesJson = input.secondaryMuscles?.let { org.json.JSONArray(it).toString() } + } + ObjectBox.store.runInTx { + exercisesBox.put(created) + val muscleCreates = input.muscles.orEmpty().map { m -> + ExerciseMuscleEntity().apply { + exercise.target = created + exerciseUid = created.uid + muscle = m.muscle.orEmpty().trim() + role = (m.role ?: "secondary").trim() + contributionFraction = m.contribution_fraction ?: 0.0 + createdAt = now + updatedAt = now + } + } + if (muscleCreates.isNotEmpty()) musclesBox.put(muscleCreates) + } + syncCompactCatalogMarkdown() + created + } + + suspend fun updateExercise(id: String, input: UpdateExerciseInput): ExerciseEntity = withContext(Dispatchers.IO) { + requireNonEmpty(id, "id") + val exercise = findExerciseByUidOrThrow(id) + val now = System.currentTimeMillis() + if (!input.name.isNullOrEmpty()) { + val updatedName = normalizeExerciseName(input.name) + exercise.name = updatedName + // Use normalizeExerciseName first to get a non-null String, then call the + // String overload of normalizeExerciseNameKey (underscore format) — consistent + // with createCustomExercise which also stores in underscore format. + exercise.normalizedName = normalizeExerciseNameKey(updatedName) + } + if (!input.primaryMuscle.isNullOrEmpty()) exercise.primaryMuscle = input.primaryMuscle + if (!input.equipment.isNullOrEmpty()) exercise.equipment = input.equipment + if (!input.category.isNullOrEmpty()) exercise.category = input.category + if (input.notes != null) exercise.notes = input.notes.ifEmpty { "" } + exercise.updatedAt = now + exercisesBox.put(exercise) + syncCompactCatalogMarkdown() + exercise + } + + suspend fun deleteCustomExercise(id: String) = withContext(Dispatchers.IO) { + requireNonEmpty(id, "id") + ObjectBox.store.runInTx { + val exercise = findExerciseByUidOrThrow(id) + if (!exercise.isCustom) error("Only custom exercises can be deleted.") + val planReferences = ObjectBox.store.boxFor(PlanExerciseEntity::class.java) + .query(PlanExerciseEntity_.exerciseUid.equal(id)).build().use { it.count() } + val workoutReferences = ObjectBox.store.boxFor(WorkoutExerciseEntity::class.java) + .query(WorkoutExerciseEntity_.exerciseUid.equal(id)).build().use { it.count() } + if (planReferences > 0 || workoutReferences > 0) { + error("This exercise is used by a plan or workout and cannot be deleted. Edit it instead.") + } + val muscles = musclesBox.query(ExerciseMuscleEntity_.exerciseUid.equal(id)).build().use { it.find() } + musclesBox.remove(muscles) + exercisesBox.remove(exercise) + } + syncCompactCatalogMarkdown() + Unit + } + + suspend fun getExerciseById(id: String): ExerciseEntity = withContext(Dispatchers.IO) { + requireNonEmpty(id, "id") + findExerciseByUidOrThrow(id) + } + + suspend fun getExerciseMuscles(exerciseId: String): List = withContext(Dispatchers.IO) { + requireNonEmpty(exerciseId, "exerciseId") + musclesBox.query(ExerciseMuscleEntity_.exerciseUid.equal(exerciseId)).build().use { it.find() } + } + + suspend fun buildExerciseAlternatives(exerciseId: String, filters: ExerciseFilters = ExerciseFilters()): SubstitutionEngine.AlternativesResult = + withContext(Dispatchers.IO) { + requireNonEmpty(exerciseId, "exerciseId") + val exercises = exercisesBox.all.map(::mapExerciseRowToLegacyShape) + val target = exercises.find { it.id == exerciseId || it.exerciseId == exerciseId } + ?: return@withContext SubstitutionEngine.AlternativesResult.EMPTY + SubstitutionEngine.buildExerciseAlternatives(target, exercises, filters, filters.limit ?: 12) + } + + internal fun findExerciseByUidOrThrow(uid: String): ExerciseEntity = + exercisesBox.query(ExerciseEntity_.uid.equal(uid)).build().use { it.findFirst() } + ?: error("Exercise not found: $uid") + + internal fun findExerciseByName(name: String): ExerciseEntity? = + exercisesBox.query(ExerciseEntity_.name.equal(name)).build().use { it.findFirst() } + + internal fun mapExerciseRowToLegacyShape(row: ExerciseEntity): LegacyExerciseShape { + val primary = if (row.primaryMuscle.isNotBlank()) listOf(row.primaryMuscle) else emptyList() + val category = row.category.ifBlank { "strength" } + val trackingType = ExerciseTrackingTypeNormalizer.normalize( + name = row.name, + category = category, + equipment = row.equipment, + explicitTrackingType = row.trackingType, + ) + return LegacyExerciseShape( + id = row.uid, + exerciseId = row.uid, + name = row.name, + primaryMuscles = primary, + primaryMuscle = row.primaryMuscle.ifBlank { null }, + secondaryMuscles = row.secondaryMusclesJson?.let { json -> + runCatching { + val arr = org.json.JSONArray(json) + (0 until arr.length()).map { arr.getString(it) } + }.getOrElse { emptyList() } + } ?: emptyList(), + equipment = row.equipment.ifBlank { "Other" }, + category = category, + trackingType = trackingType, + isCustom = row.isCustom, + aliases = emptyList(), + isBodyweight = row.isBodyweight || row.equipment.lowercase() == "bodyweight", + movementPattern = row.movementPattern, + difficulty = row.difficulty, + apparatus = row.apparatusJson, + equipmentDetail = row.equipmentDetail, + sourceTags = emptyList(), + notes = row.notes, + ) + } + + private fun filterRows(rows: List, filters: ExerciseFilters): List = rows.filter { row -> + val primaryOk = filters.primaryMuscle.isNullOrBlank() || filters.primaryMuscle == "all" || row.primaryMuscle == filters.primaryMuscle + val equipmentOk = filters.equipment.isNullOrBlank() || filters.equipment == "all" || row.equipment == filters.equipment + val categoryOk = filters.category.isNullOrBlank() || filters.category == "all" || row.category == filters.category + val customOk = !filters.customOnly || row.isCustom + primaryOk && equipmentOk && categoryOk && customOk + } + + private fun syncCompactCatalogMarkdown() { + val ctx = context ?: return + val file = catalogMarkdownFile(ctx) + val rows = filterRows(exercisesBox.all, ExerciseFilters()).map(::mapExerciseRowToLegacyShape) + file.parentFile?.mkdirs() + file.writeText(buildCompactCatalogMarkdown(rows)) + } + + private fun catalogMarkdownFile(ctx: Context): File = + File(ctx.filesDir, "ai/ironlog_exercise_catalog_compact.md") + + private fun buildCompactCatalogMarkdown(exercises: List): String { + val header = """ +# Ironlog Exercise Catalog (Compact) + +Use this catalog as the source of truth for exercise naming and metadata. +Format: `Exercise Name | Primary Muscle | Equipment | Category | Tracking Type | Movement Pattern` +""".trimIndent() + val lines = exercises + .sortedBy { it.name.lowercase() } + .map { ex -> + val primary = ex.primaryMuscle ?: ex.primaryMuscles.firstOrNull() ?: "Unknown" + "${ex.name} | $primary | ${ex.equipment} | ${ex.category} | ${ex.trackingType} | ${ex.movementPattern}" + } + return buildString { + appendLine(header) + appendLine() + appendLine("```text") + lines.forEach { appendLine(it) } + appendLine("```") + } + } +} diff --git a/app/src/main/java/com/ironlog/app/data/repository/HistoryRepository.kt b/app/src/main/java/com/ironlog/app/data/repository/HistoryRepository.kt new file mode 100644 index 0000000..30298ef --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/repository/HistoryRepository.kt @@ -0,0 +1,63 @@ +package com.ironlog.app.data.repository + +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.data.objectbox.WorkoutEntity +import com.ironlog.app.data.objectbox.WorkoutEntity_ +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity_ +import com.ironlog.app.data.objectbox.WorkoutSetEntity +import com.ironlog.app.data.objectbox.WorkoutSetEntity_ +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class HistoryRepository { + private val workoutBox get() = ObjectBox.store.boxFor(WorkoutEntity::class.java) + private val workoutExerciseBox get() = ObjectBox.store.boxFor(WorkoutExerciseEntity::class.java) + private val workoutSetBox get() = ObjectBox.store.boxFor(WorkoutSetEntity::class.java) + + suspend fun updateWorkout( + uid: String, + startedAt: Long? = null, + durationSeconds: Int? = null, + rating: Double? = null, + notes: String? = null, + ): WorkoutEntity = withContext(Dispatchers.IO) { + val row = workoutBox.query(WorkoutEntity_.uid.equal(uid)).build().use { it.findFirst() } + ?: error("Workout not found: $uid") + startedAt?.let { row.startedAt = it } + durationSeconds?.let { row.durationSeconds = kotlin.math.max(0, it) } + rating?.let { row.rating = it } + if (notes != null) row.notes = notes + row.updatedAt = System.currentTimeMillis() + workoutBox.put(row) + row + } + + suspend fun deleteWorkout(uid: String) = withContext(Dispatchers.IO) { + deleteWorkoutsBlocking(listOf(uid)) + } + + suspend fun deleteWorkouts(ids: List) = withContext(Dispatchers.IO) { + deleteWorkoutsBlocking(ids) + } + + private fun deleteWorkoutsBlocking(ids: List) { + if (ids.isEmpty()) return + ObjectBox.store.runInTx { + val rows = workoutBox.query(WorkoutEntity_.uid.oneOf(ids.distinct().toTypedArray())) + .build().use { it.find() } + if (rows.isEmpty()) return@runInTx + val rowUids = rows.map { it.uid }.toTypedArray() + val exercises = workoutExerciseBox.query(WorkoutExerciseEntity_.workoutUid.oneOf(rowUids)) + .build().use { it.find() } + val exerciseUids = exercises.map { it.uid }.toTypedArray() + val sets = if (exerciseUids.isEmpty()) emptyList() else { + workoutSetBox.query(WorkoutSetEntity_.workoutExerciseUid.oneOf(exerciseUids)) + .build().use { it.find() } + } + workoutSetBox.remove(sets) + workoutExerciseBox.remove(exercises) + workoutBox.remove(rows) + } + } +} diff --git a/app/src/main/java/com/ironlog/app/data/repository/ImportExportRepository.kt b/app/src/main/java/com/ironlog/app/data/repository/ImportExportRepository.kt new file mode 100644 index 0000000..16d451b --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/repository/ImportExportRepository.kt @@ -0,0 +1,1331 @@ +package com.ironlog.app.data.repository + +import com.ironlog.app.data.objectbox.AppSettingEntity +import com.ironlog.app.data.objectbox.AppSettingEntity_ +import com.ironlog.app.data.objectbox.AthleteCalibrationEntity +import com.ironlog.app.data.objectbox.AthleteCalibrationEntity_ +import com.ironlog.app.data.objectbox.BodyMeasurementEntity +import com.ironlog.app.data.objectbox.BodyMeasurementEntity_ +import com.ironlog.app.data.objectbox.ExerciseEntity +import com.ironlog.app.data.objectbox.ExerciseEntity_ +import com.ironlog.app.data.objectbox.ExerciseMuscleEntity +import com.ironlog.app.data.objectbox.ExerciseMuscleEntity_ +import com.ironlog.app.data.objectbox.GamificationProfileEntity +import com.ironlog.app.data.objectbox.GamificationProfileEntity_ +import com.ironlog.app.data.objectbox.IronLedgerEventEntity +import com.ironlog.app.data.objectbox.IronLedgerEventEntity_ +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.data.objectbox.PlanDayEntity +import com.ironlog.app.data.objectbox.PlanDayEntity_ +import com.ironlog.app.data.objectbox.PlanEntity +import com.ironlog.app.data.objectbox.PlanEntity_ +import com.ironlog.app.data.objectbox.PlanExerciseEntity +import com.ironlog.app.data.objectbox.PlanExerciseEntity_ +import com.ironlog.app.data.objectbox.ProgressPhotoEntity +import com.ironlog.app.data.objectbox.ProgressPhotoEntity_ +import com.ironlog.app.data.objectbox.WorkoutEntity +import com.ironlog.app.data.objectbox.WorkoutEntity_ +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity_ +import com.ironlog.app.data.objectbox.WorkoutImportProvenanceMigration +import com.ironlog.app.data.objectbox.WorkoutSetEntity +import com.ironlog.app.data.objectbox.WorkoutSetEntity_ +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import org.json.JSONArray +import org.json.JSONObject +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import kotlin.math.max + +internal val FULL_BACKUP_DATA_SECTIONS = setOf( + "exercises", + "exercise_muscles", + "plans", + "plan_days", + "plan_exercises", + "workouts", + "workout_exercises", + "workout_sets", + "body_measurements", + "progress_photos", + "app_settings", + "athlete_calibrations", + "gamification_profiles", + "iron_ledger_events", +) + +internal fun parseImportEpochMillis( + value: Any?, + fallback: Long, + zoneId: ZoneId = ZoneId.systemDefault(), +): Long = when (value) { + is Number -> value.toLong() + is String -> value.trim().takeIf { it.isNotEmpty() }?.let { raw -> + raw.toLongOrNull() + ?: runCatching { Instant.parse(raw).toEpochMilli() }.getOrNull() + ?: runCatching { LocalDate.parse(raw).atStartOfDay(zoneId).toInstant().toEpochMilli() }.getOrNull() + ?: runCatching { LocalDateTime.parse(raw.replace(" ", "T")).atZone(zoneId).toInstant().toEpochMilli() }.getOrNull() + } ?: fallback + else -> fallback +} + +data class ImportCounts( + val exercises: Int = 0, + val exerciseMuscles: Int = 0, + val plans: Int = 0, + val planDays: Int = 0, + val planExercises: Int = 0, + val workouts: Int = 0, + val workoutExercises: Int = 0, + val sets: Int = 0, + val bodyMeasurements: Int = 0, + val progressPhotos: Int = 0, + val settings: Int = 0, + val athleteCalibrations: Int = 0, + val gamificationProfiles: Int = 0, + val ironLedgerEvents: Int = 0, +) + +data class ImportPreview( + val valid: Boolean, + val format: String = "unknown", + val convertedFrom: String? = null, + val reason: String? = null, + val errors: List = emptyList(), + val warnings: List = emptyList(), + val counts: ImportCounts = ImportCounts(), + val workouts: Int = 0, + val plans: Int = 0, + val sets: Int = 0, + val bodyMeasurements: Int = 0, + val settings: Int = 0, + val customExercises: List = emptyList(), + val duplicateExerciseNames: List = emptyList(), + val relationshipWarnings: List = emptyList(), + val unsupportedRows: Int = 0, +) + +class ImportExportRepository( +) { + companion object { + const val EXPORT_TYPE = "ironlog_watermelon_export" + const val EXPORT_VERSION = 1 + } + + suspend fun exportDatabase(): JSONObject = withContext(Dispatchers.IO) { + ObjectBox.store.callInReadTx { + val exercises = ObjectBox.store.boxFor(ExerciseEntity::class.java).all + val exerciseMuscles = ObjectBox.store.boxFor(ExerciseMuscleEntity::class.java).all + val plans = ObjectBox.store.boxFor(PlanEntity::class.java).all + val planDays = ObjectBox.store.boxFor(PlanDayEntity::class.java).all + val planExercises = ObjectBox.store.boxFor(PlanExerciseEntity::class.java).all + val workouts = ObjectBox.store.boxFor(WorkoutEntity::class.java).all + val workoutExercises = ObjectBox.store.boxFor(WorkoutExerciseEntity::class.java).all + val workoutSets = ObjectBox.store.boxFor(WorkoutSetEntity::class.java).all + val body = ObjectBox.store.boxFor(BodyMeasurementEntity::class.java).all + val photos = ObjectBox.store.boxFor(ProgressPhotoEntity::class.java).all + val settings = ObjectBox.store.boxFor(AppSettingEntity::class.java).all + val athleteCalibrations = ObjectBox.store.boxFor(AthleteCalibrationEntity::class.java).all + val gamificationProfiles = ObjectBox.store.boxFor(GamificationProfileEntity::class.java).all + val ironLedgerEvents = ObjectBox.store.boxFor(IronLedgerEventEntity::class.java).all + + JSONObject() + .put("version", EXPORT_VERSION) + .put("type", EXPORT_TYPE) + .put("exportedAt", java.time.Instant.now().toString()) + .put( + "data", + JSONObject() + .put("exercises", JSONArray().apply { exercises.forEach { put(JSONObject().put("id", it.uid).put("name", it.name).put("normalized_name", it.normalizedName).put("primary_muscle", it.primaryMuscle).put("equipment", it.equipment).put("category", it.category).put("is_custom", it.isCustom).put("source", it.source).put("notes", it.notes).put("equipment_detail", it.equipmentDetail ?: JSONObject.NULL).put("apparatus_json", it.apparatusJson ?: JSONObject.NULL).put("tracking_type", it.trackingType ?: JSONObject.NULL).put("is_bodyweight", it.isBodyweight).put("requires_external_load", it.requiresExternalLoad).put("movement_pattern", it.movementPattern ?: JSONObject.NULL).put("difficulty", it.difficulty ?: JSONObject.NULL).put("aliases_json", it.aliasesJson ?: JSONObject.NULL).put("source_tags_json", it.sourceTagsJson ?: JSONObject.NULL).put("secondary_muscles_json", it.secondaryMusclesJson ?: JSONObject.NULL).put("created_at", it.createdAt).put("updated_at", it.updatedAt)) } }) + .put("exercise_muscles", JSONArray().apply { exerciseMuscles.forEach { put(JSONObject().put("id", it.uid).put("exercise_id", it.exerciseUid).put("muscle", it.muscle).put("role", it.role).put("contribution_fraction", it.contributionFraction).put("created_at", it.createdAt).put("updated_at", it.updatedAt)) } }) + .put("plans", JSONArray().apply { plans.forEach { put(JSONObject().put("id", it.uid).put("name", it.name).put("goal", it.goal).put("description", it.description).put("is_active", it.isActive).put("created_at", it.createdAt).put("updated_at", it.updatedAt)) } }) + .put("plan_days", JSONArray().apply { planDays.forEach { put(JSONObject().put("id", it.uid).put("plan_id", it.planUid).put("name", it.name).put("color", it.color).put("order_index", it.orderIndex).put("created_at", it.createdAt).put("updated_at", it.updatedAt)) } }) + .put("plan_exercises", JSONArray().apply { planExercises.forEach { put(JSONObject().put("id", it.uid).put("plan_day_id", it.planDayUid).put("exercise_id", it.exerciseUid).put("order_index", it.orderIndex).put("sets", it.sets).put("reps", it.reps).put("rest_seconds", it.restSeconds).put("superset_group", it.supersetGroup).put("is_warmup", it.isWarmup).put("notes", it.notes).put("created_at", it.createdAt).put("updated_at", it.updatedAt)) } }) + .put("workouts", JSONArray().apply { workouts.forEach { put(JSONObject().put("id", it.uid).put("plan_id", it.planUid ?: "").put("plan_day_id", it.planDayUid ?: "").put("name", it.name).put("started_at", it.startedAt).put("completed_at", it.completedAt ?: JSONObject.NULL).put("duration_seconds", it.durationSeconds).put("rating", it.rating ?: JSONObject.NULL).put("notes", it.notes).put("status", it.status).put("imported", it.imported).put("created_at", it.createdAt).put("updated_at", it.updatedAt)) } }) + .put("workout_exercises", JSONArray().apply { workoutExercises.forEach { put(JSONObject().put("id", it.uid).put("workout_id", it.workoutUid).put("exercise_id", it.exerciseUid).put("order_index", it.orderIndex).put("superset_group", it.supersetGroup).put("notes", it.notes).put("created_at", it.createdAt).put("updated_at", it.updatedAt)) } }) + .put("workout_sets", JSONArray().apply { workoutSets.forEach { put(JSONObject().put("id", it.uid).put("workout_exercise_id", it.workoutExerciseUid).put("set_index", it.setIndex).put("weight", it.weight).put("reps", it.reps).put("rpe", it.rpe ?: JSONObject.NULL).put("rir", it.rir ?: JSONObject.NULL).put("rest_seconds", it.restSeconds).put("is_warmup", it.isWarmup).put("is_dropset", it.isDropset).put("is_amrap", it.isAmrap).put("to_failure", it.toFailure).put("completed_at", it.completedAt ?: JSONObject.NULL).put("created_at", it.createdAt).put("updated_at", it.updatedAt)) } }) + .put("body_measurements", JSONArray().apply { body.forEach { put(JSONObject().put("id", it.uid).put("measured_at", it.measuredAt).put("bodyweight", it.bodyweight ?: JSONObject.NULL).put("waist", it.waist ?: JSONObject.NULL).put("chest", it.chest ?: JSONObject.NULL).put("arm", it.arm ?: JSONObject.NULL).put("thigh", it.thigh ?: JSONObject.NULL).put("notes", it.notes).put("created_at", it.createdAt).put("updated_at", it.updatedAt)) } }) + .put("progress_photos", JSONArray().apply { photos.forEach { put(JSONObject().put("id", it.uid).put("file_uri", it.fileUri).put("taken_at", it.takenAt).put("bodyweight", it.bodyweight ?: JSONObject.NULL).put("notes", it.notes).put("created_at", it.createdAt).put("updated_at", it.updatedAt)) } }) + .put("app_settings", JSONArray().apply { settings.forEach { put(JSONObject().put("id", it.key).put("key", it.key).put("value", it.value).put("value_type", it.valueType).put("updated_at", it.updatedAt)) } }) + .put("athlete_calibrations", JSONArray().apply { + athleteCalibrations.forEach { + put( + JSONObject() + .put("offline_user_id", it.offlineUserId) + .put("training_age_months", it.trainingAgeMonths) + .put("historical_training_days_per_week", it.historicalTrainingDaysPerWeek) + .put("first_verified_session_at", it.firstVerifiedSessionAt) + .put("weight_unit", it.weightUnit) + .put("bodyweight_kg", it.bodyweightKg ?: JSONObject.NULL) + .put("goal_mode", it.goalMode) + .put("weekly_goal_days", it.weeklyGoalDays) + .put("imported_history", it.importedHistory) + .put("confidence", it.confidence) + .put("updated_at", it.updatedAt) + .put("has_past_training", it.hasPastTraining) + .put("has_gym_access", it.hasGymAccess) + .put("baseline_pushups", it.baselinePushups) + .put("baseline_pullups", it.baselinePullups) + .put("baseline_bench_kg", it.baselineBenchKg) + .put("baseline_lat_pulldown_kg", it.baselineLatPulldownKg) + .put("baseline_mile_run_seconds", it.baselineMileRunSeconds) + ) + } + }) + .put("gamification_profiles", JSONArray().apply { + gamificationProfiles.forEach { + put( + JSONObject() + .put("offline_user_id", it.offlineUserId) + .put("total_xp", it.totalXp) + .put("level", it.level) + .put("xp_in_level", it.xpInLevel) + .put("rank", it.rank) + .put("active_title", it.activeTitle) + .put("stats_json", it.statsJson) + .put("weekly_xp", it.weeklyXp) + .put("weekly_xp_reset_week", it.weeklyXpResetWeek) + .put("streak_weeks", it.streakWeeks) + .put("recovery_circuit_completions_json", it.makeupCompletionsJson) + .put("unlocked_badges", it.unlockedBadges) + ) + } + }) + .put("iron_ledger_events", JSONArray().apply { + ironLedgerEvents.forEach { + put( + JSONObject() + .put("event_id", it.eventId) + .put("source_type", it.sourceType) + .put("source_id", it.sourceId) + .put("event_kind", it.eventKind) + .put("occurred_at", it.occurredAt) + .put("xp_delta", it.xpDelta) + .put("stat_deltas_json", it.statDeltasJson) + .put("trust_score", it.trustScore) + .put("fingerprint", it.fingerprint) + .put("metadata_json", it.metadataJson) + .put("invalidated", it.invalidated) + ) + } + }), + ) + } + } + + private fun isIronLogExport(payload: JSONObject): Boolean { + return payload.optString("type") == EXPORT_TYPE && payload.optInt("version") == 1 + } + + private fun isLegacySQLiteBundle(payload: JSONObject): Boolean { + val schema = payload.optString("schema", "") + return schema.startsWith("IRONLOG_SQLITE_EXPORT_V") && payload.optJSONObject("payload") != null + } + + private fun detectImportFormat(payload: JSONObject): String { + if (isIronLogExport(payload)) return "ironlog_v1" + if (isLegacySQLiteBundle(payload)) return "sqlite_v1" + if (payload.has("domains") || payload.has("plans") || payload.has("history") || payload.has("bodyWeight") || payload.has("customExercises") || payload.optJSONObject("payload")?.has("plans") == true) { + return "legacy_async" + } + return "unknown" + } + + private fun countRows(data: JSONObject?, table: String): Int { + return data?.optJSONArray(table)?.length() ?: 0 + } + + private fun buildImportCounts(data: JSONObject?): ImportCounts { + return ImportCounts( + exercises = countRows(data, "exercises"), + exerciseMuscles = countRows(data, "exercise_muscles"), + plans = countRows(data, "plans"), + planDays = countRows(data, "plan_days"), + planExercises = countRows(data, "plan_exercises"), + workouts = countRows(data, "workouts"), + workoutExercises = countRows(data, "workout_exercises"), + sets = countRows(data, "workout_sets"), + bodyMeasurements = countRows(data, "body_measurements"), + progressPhotos = countRows(data, "progress_photos"), + settings = countRows(data, "app_settings"), + athleteCalibrations = countRows(data, "athlete_calibrations"), + gamificationProfiles = countRows(data, "gamification_profiles"), + ironLedgerEvents = countRows(data, "iron_ledger_events"), + ) + } + + private fun findDuplicateExerciseNames(exercises: JSONArray?): List { + if (exercises == null) return emptyList() + val seen = mutableMapOf>() + for (i in 0 until exercises.length()) { + val row = exercises.optJSONObject(i) ?: continue + val norm = row.optString("normalized_name").ifEmpty { row.optString("name") } + val key = norm.lowercase().replace(Regex("[^a-z0-9]"), "").trim() + if (key.isEmpty()) continue + if (!seen.containsKey(key)) seen[key] = mutableListOf() + seen[key]!!.add(row.optString("name").ifEmpty { row.optString("normalized_name", key) }) + } + return seen.values.filter { it.size > 1 }.map { it.first() }.take(12) + } + + private fun buildRelationshipWarnings(data: JSONObject?): List { + val warnings = mutableListOf() + if (data == null) return warnings + fun ids(table: String) = (data.optJSONArray(table) ?: JSONArray()).let { arr -> + (0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("id") }.toSet() + } + val planIds = ids("plans") + val planDayIds = ids("plan_days") + val exerciseIds = ids("exercises") + val workoutIds = ids("workouts") + val workoutExerciseIds = ids("workout_exercises") + + val missingPlanDays = (data.optJSONArray("plan_days") ?: JSONArray()).let { arr -> + (0 until arr.length()).count { arr.optJSONObject(it)?.optString("plan_id")?.isNotBlank() == true && !planIds.contains(arr.optJSONObject(it)?.optString("plan_id")) } + } + if (missingPlanDays > 0) warnings.add("$missingPlanDays plan day${if(missingPlanDays==1) "" else "s"} reference missing plans.") + + val missingPlanExerciseDays = (data.optJSONArray("plan_exercises") ?: JSONArray()).let { arr -> + (0 until arr.length()).count { arr.optJSONObject(it)?.optString("plan_day_id")?.isNotBlank() == true && !planDayIds.contains(arr.optJSONObject(it)?.optString("plan_day_id")) } + } + if (missingPlanExerciseDays > 0) warnings.add("$missingPlanExerciseDays planned exercise${if(missingPlanExerciseDays==1) "" else "s"} reference missing days.") + + val missingPlanExerciseExercises = (data.optJSONArray("plan_exercises") ?: JSONArray()).let { arr -> + if (exerciseIds.isEmpty()) 0 else (0 until arr.length()).count { arr.optJSONObject(it)?.optString("exercise_id")?.isNotBlank() == true && !exerciseIds.contains(arr.optJSONObject(it)?.optString("exercise_id")) } + } + if (missingPlanExerciseExercises > 0) warnings.add("$missingPlanExerciseExercises planned exercise${if(missingPlanExerciseExercises==1) "" else "s"} reference missing exercises.") + + val missingWorkoutExerciseWorkouts = (data.optJSONArray("workout_exercises") ?: JSONArray()).let { arr -> + (0 until arr.length()).count { arr.optJSONObject(it)?.optString("workout_id")?.isNotBlank() == true && !workoutIds.contains(arr.optJSONObject(it)?.optString("workout_id")) } + } + if (missingWorkoutExerciseWorkouts > 0) warnings.add("$missingWorkoutExerciseWorkouts workout exercise${if(missingWorkoutExerciseWorkouts==1) "" else "s"} reference missing workouts.") + + val missingWorkoutExerciseExercises = (data.optJSONArray("workout_exercises") ?: JSONArray()).let { arr -> + if (exerciseIds.isEmpty()) 0 else (0 until arr.length()).count { arr.optJSONObject(it)?.optString("exercise_id")?.isNotBlank() == true && !exerciseIds.contains(arr.optJSONObject(it)?.optString("exercise_id")) } + } + if (missingWorkoutExerciseExercises > 0) warnings.add("$missingWorkoutExerciseExercises workout exercise${if(missingWorkoutExerciseExercises==1) "" else "s"} reference missing exercises.") + + val missingSetParents = (data.optJSONArray("workout_sets") ?: JSONArray()).let { arr -> + (0 until arr.length()).count { arr.optJSONObject(it)?.optString("workout_exercise_id")?.isNotBlank() == true && !workoutExerciseIds.contains(arr.optJSONObject(it)?.optString("workout_exercise_id")) } + } + if (missingSetParents > 0) warnings.add("$missingSetParents set${if(missingSetParents==1) "" else "s"} reference missing workout exercises.") + + return warnings + } + + private fun getUnsupportedRows(data: JSONObject?): Int { + if (data == null) return 0 + var count = 0 + data.keys().forEach { if (it !in FULL_BACKUP_DATA_SECTIONS) count++ } + return count + } + + fun previewImportPayload(text: String): ImportPreview { + val raw = runCatching { JSONObject(text) } + .onFailure { Timber.w(it, "previewImportPayload: invalid JSON") } + .getOrNull() ?: return ImportPreview(valid = false, format = "invalid_json", reason = "Invalid JSON", errors = listOf("Invalid JSON")) + val format = detectImportFormat(raw) + if (format == "unknown") return ImportPreview(valid = false, format = format, reason = "Unsupported payload format.", errors = listOf("Unsupported payload format.")) + + var normalizedPayload = raw + var convertedFrom: String? = null + val warnings = mutableListOf() + + try { + if (format != "ironlog_v1") { + val conv = convertLegacyToIronLogExport(raw) + normalizedPayload = conv + convertedFrom = if (isLegacySQLiteBundle(raw)) "sqlite_v1" else "legacy_async" + warnings.add("Legacy $convertedFrom backup will be normalized into IronLog format.") + } + } catch (e: Exception) { + return ImportPreview(valid = false, format = format, reason = e.message ?: "Normalization failed", errors = listOf(e.message ?: "Normalization failed")) + } + + if (normalizedPayload.optString("type") != EXPORT_TYPE || normalizedPayload.optInt("version") != EXPORT_VERSION) { + return ImportPreview(valid = false, format = format, reason = "Unsupported backup format", errors = listOf("Unsupported backup format")) + } + val data = normalizedPayload.optJSONObject("data") ?: return ImportPreview(valid = false, format = format, reason = "Missing data object", errors = listOf("Missing data object")) + + val counts = buildImportCounts(data) + val duplicateExerciseNames = findDuplicateExerciseNames(data.optJSONArray("exercises")) + val relationshipWarnings = buildRelationshipWarnings(data) + val unsupportedRows = getUnsupportedRows(data) + + val customExercisesArr = data.optJSONArray("exercises") ?: JSONArray() + val customExercises = mutableListOf() + for (i in 0 until customExercisesArr.length()) { + val ex = customExercisesArr.optJSONObject(i) ?: continue + if (ex.optBoolean("is_custom", false) || ex.optString("source") == "user_custom") { + val name = ex.optString("name") + if (name.isNotBlank()) customExercises.add(name) + } + } + + if (duplicateExerciseNames.isNotEmpty()) warnings.add("${duplicateExerciseNames.size} duplicate exercise name group${if (duplicateExerciseNames.size == 1) "" else "s"} detected in this file.") + warnings.addAll(relationshipWarnings) + if (unsupportedRows > 0) warnings.add("$unsupportedRows unsupported data section${if (unsupportedRows == 1) "" else "s"} will be ignored.") + + return ImportPreview( + valid = true, + format = format, + convertedFrom = convertedFrom, + reason = null, + errors = emptyList(), + warnings = warnings, + counts = counts, + workouts = counts.workouts, + plans = counts.plans, + sets = counts.sets, + bodyMeasurements = counts.bodyMeasurements, + settings = counts.settings, + customExercises = customExercises.take(20), + duplicateExerciseNames = duplicateExerciseNames, + relationshipWarnings = relationshipWarnings, + unsupportedRows = unsupportedRows + ) + } + + suspend fun runConfirmedImport(text: String, mode: String = "replace"): ImportPreview = withContext(Dispatchers.IO) { + val raw = runCatching { JSONObject(text) } + .onFailure { Timber.e(it, "runConfirmedImport: failed to parse backup JSON") } + .getOrElse { throw IllegalArgumentException("Corrupt or empty backup file: ${it.message}", it) } + val preview = previewImportPayload(text) + require(preview.valid) { preview.reason ?: "Invalid payload" } + val normalized = normalizePayloadForImport(raw) + backfillMissingAthleteStateRows(normalized.getJSONObject("data")) + val hasImportedExercises = (normalized.optJSONObject("data")?.optJSONArray("exercises")?.length() ?: 0) > 0 + ObjectBox.store.runInTx { + if (mode == "replace") clearUserDataTables(clearExercises = hasImportedExercises) + importData(normalized.getJSONObject("data"), mergeSingletons = mode != "replace") + } + // Only flag as imported-history when actual workout rows were present + val importedWorkouts = (normalized.optJSONObject("data")?.optJSONArray("workouts")?.length() ?: 0) + if (importedWorkouts > 0) { + ObjectBox.store.runInTx { markLedgerImportedHistory() } + } + WorkoutImportProvenanceMigration.run() + preview + } + + private fun markLedgerImportedHistory() { + val now = System.currentTimeMillis() + val settingsBox = ObjectBox.store.boxFor(AppSettingEntity::class.java) + settingsBox.put(AppSettingEntity().apply { + key = "ledger_imported_history" + value = "true" + valueType = "boolean" + updatedAt = now + }) + } + + private fun normalizePayloadForImport(raw: JSONObject): JSONObject { + if (raw.optString("type") == EXPORT_TYPE && raw.optInt("version") == EXPORT_VERSION && raw.optJSONObject("data") != null) return raw + return convertLegacyToIronLogExport(raw) + } + + private fun convertLegacyToIronLogExport(raw: JSONObject): JSONObject { + val now = System.currentTimeMillis() + val data = JSONObject() + .put("exercises", JSONArray()) + .put("exercise_muscles", JSONArray()) + .put("plans", JSONArray()) + .put("plan_days", JSONArray()) + .put("plan_exercises", JSONArray()) + .put("workouts", JSONArray()) + .put("workout_exercises", JSONArray()) + .put("workout_sets", JSONArray()) + .put("body_measurements", JSONArray()) + .put("progress_photos", JSONArray()) + .put("app_settings", JSONArray()) + .put("athlete_calibrations", JSONArray()) + .put("gamification_profiles", JSONArray()) + .put("iron_ledger_events", JSONArray()) + + val (snapshot, appState) = extractLegacySnapshot(raw) + val exerciseByNorm = linkedMapOf() + var exCounter = 0 + var planCounter = 0 + var workoutCounter = 0 + var bodyCounter = 0 + + fun normalizeName(name: String): String = name.trim().replace(Regex("\\s+"), " ") + fun normalizeKey(name: String): String = normalizeName(name).lowercase().replace(Regex("[^a-z0-9]+"), " ").trim() + fun stableId(prefix: String, rawId: String?, index: Int): String { + val cleaned = rawId?.trim().orEmpty() + return if (cleaned.isNotBlank()) "legacy_${prefix}_${cleaned.replace(Regex("[^a-zA-Z0-9_-]"), "_")}" else "legacy_${prefix}_${prefix.hashCode().and(0xFFFFFF).toString(16)}_${index}" + } + fun ensureExercise(obj: JSONObject): String { + val name = normalizeName(obj.optString("name", obj.optString("exerciseName", obj.optString("title", "Exercise")))) + val norm = normalizeKey(name) + exerciseByNorm[norm]?.let { return it } + exCounter++ + val id = stableId("exercise", obj.optString("id"), exCounter) + val primary = obj.optString("primaryMuscle", obj.optString("muscleGroup", obj.optString("muscle", "Other"))) + val equipment = obj.optString("equipment", "Other") + val category = obj.optString("category", obj.optString("type", "strength")) + val isCustom = if (obj.has("isCustom")) obj.optBoolean("isCustom", true) else true + data.getJSONArray("exercises").put( + JSONObject() + .put("id", id) + .put("name", name) + .put("normalized_name", norm) + .put("primary_muscle", primary) + .put("equipment", equipment) + .put("category", category) + .put("is_custom", isCustom) + .put("source", if (isCustom) "user_custom" else "import") + .put("notes", obj.optString("notes")) + .put("created_at", now) + .put("updated_at", now), + ) + data.getJSONArray("exercise_muscles").put( + JSONObject() + .put("id", stableId("exercise_muscle", "${id}_primary", exCounter)) + .put("exercise_id", id) + .put("muscle", primary) + .put("role", "primary") + .put("contribution_fraction", 1.0) + .put("created_at", now) + .put("updated_at", now), + ) + exerciseByNorm[norm] = id + return id + } + + (snapshot.optJSONArray("customExercises") ?: JSONArray()).forEachObject { ensureExercise(it) } + + (snapshot.optJSONArray("plans") ?: JSONArray()).forEachObject { plan -> + planCounter++ + val planId = stableId("plan", plan.optString("id"), planCounter) + data.getJSONArray("plans").put( + JSONObject() + .put("id", planId) + .put("name", plan.optString("name", "Imported Plan $planCounter")) + .put("goal", plan.optString("goal", "General Fitness")) + .put("description", plan.optString("description")) + .put("is_active", plan.optBoolean("isActive", false)) + .put("created_at", now) + .put("updated_at", now), + ) + val days = if (plan.optJSONArray("days") != null) plan.optJSONArray("days")!! else (plan.optJSONArray("workoutDays") ?: JSONArray()) + for (dIdx in 0 until days.length()) { + val day = days.optJSONObject(dIdx) ?: continue + val dayId = stableId("plan_day", day.optString("id"), dIdx + 1) + data.getJSONArray("plan_days").put( + JSONObject() + .put("id", dayId) + .put("plan_id", planId) + .put("name", day.optString("name", day.optString("title", "Day ${dIdx + 1}"))) + .put("color", day.optString("color", "#FF4500")) + .put("order_index", dIdx) + .put("created_at", now) + .put("updated_at", now), + ) + val exercises = if (day.optJSONArray("exercises") != null) day.optJSONArray("exercises")!! else (day.optJSONArray("items") ?: JSONArray()) + for (eIdx in 0 until exercises.length()) { + val ex = exercises.optJSONObject(eIdx) ?: continue + val exId = ensureExercise(ex) + data.getJSONArray("plan_exercises").put( + JSONObject() + .put("id", stableId("plan_exercise", ex.optString("id"), eIdx + 1)) + .put("plan_day_id", dayId) + .put("exercise_id", exId) + .put("order_index", eIdx) + .put("sets", max(1, ex.optInt("sets", 3))) + .put("reps", ex.optString("reps", ex.optString("repRange", "8-12"))) + .put("rest_seconds", max(0, ex.optInt("restSeconds", ex.optInt("rest", 90)))) + .put("superset_group", ex.optString("supersetGroup")) + .put("is_warmup", ex.optBoolean("isWarmup", false)) + .put("notes", ex.optString("notes")) + .put("created_at", now) + .put("updated_at", now), + ) + } + } + } + + (snapshot.optJSONArray("history") ?: JSONArray()).forEachObject { workout -> + workoutCounter++ + val dateFallback = parseImportEpochMillis(workout.opt("date"), now) + val startedAt = parseImportEpochMillis( + workout.opt("startedAt").takeUnless { it == null || it == JSONObject.NULL } + ?: workout.opt("timestamp"), + dateFallback, + ) + val completedAt = parseImportEpochMillis(workout.opt("completedAt"), startedAt) + val workoutId = stableId("workout", workout.optString("id"), workoutCounter) + data.getJSONArray("workouts").put( + JSONObject() + .put("id", workoutId) + .put("plan_id", "") + .put("plan_day_id", "") + .put("name", workout.optString("name", workout.optString("day", "Imported Workout $workoutCounter"))) + .put("started_at", startedAt) + .put("completed_at", completedAt) + .put("duration_seconds", max(0, workout.optInt("durationSeconds", workout.optInt("duration", 0)))) + .put("rating", if (workout.has("rating")) workout.optDouble("rating") else JSONObject.NULL) + .put("notes", workout.optString("notes")) + .put("status", workout.optString("status", "completed")) + .put("imported", true) + .put("created_at", startedAt) + .put("updated_at", completedAt), + ) + val wExercises = if (workout.optJSONArray("exercises") != null) workout.optJSONArray("exercises")!! else (workout.optJSONArray("items") ?: JSONArray()) + for (eIdx in 0 until wExercises.length()) { + val ex = wExercises.optJSONObject(eIdx) ?: continue + val workoutExerciseId = stableId("workout_exercise", ex.optString("id"), eIdx + 1) + val exerciseId = ensureExercise(ex) + data.getJSONArray("workout_exercises").put( + JSONObject() + .put("id", workoutExerciseId) + .put("workout_id", workoutId) + .put("exercise_id", exerciseId) + .put("order_index", eIdx) + .put("superset_group", ex.optString("supersetGroup")) + .put("notes", ex.optString("notes")) + .put("created_at", startedAt) + .put("updated_at", completedAt), + ) + val sets = if (ex.optJSONArray("sets") != null) ex.optJSONArray("sets")!! else (ex.optJSONArray("logs") ?: JSONArray()) + for (sIdx in 0 until sets.length()) { + val set = sets.optJSONObject(sIdx) ?: continue + data.getJSONArray("workout_sets").put( + JSONObject() + .put("id", stableId("workout_set", set.optString("id"), sIdx + 1)) + .put("workout_exercise_id", workoutExerciseId) + .put("set_index", sIdx + 1) + .put("weight", max(0.0, set.optDouble("weight", 0.0))) + .put("reps", max(0.0, set.optDouble("reps", 0.0))) + .put("rpe", if (set.has("rpe")) set.optDouble("rpe") else JSONObject.NULL) + .put("rir", if (set.has("rir")) set.optDouble("rir") else JSONObject.NULL) + .put("rest_seconds", max(0, set.optInt("restSeconds", set.optInt("rest", 0)))) + .put("is_warmup", set.optBoolean("isWarmup", false)) + .put("is_dropset", set.optBoolean("isDropset", false)) + .put("is_amrap", set.optBoolean("isAmrap", set.optBoolean("isAMRAP", false))) + .put("to_failure", set.optBoolean("toFailure", false)) + .put("completed_at", completedAt) + .put("created_at", startedAt) + .put("updated_at", completedAt), + ) + } + } + } + + (snapshot.optJSONArray("bodyWeight") ?: JSONArray()).forEachObject { row -> + bodyCounter++ + val measuredAt = parseImportEpochMillis( + row.opt("measuredAt").takeUnless { it == null || it == JSONObject.NULL } ?: row.opt("date"), + now + bodyCounter, + ) + data.getJSONArray("body_measurements").put( + JSONObject() + .put("id", stableId("body", row.optString("id"), bodyCounter)) + .put("measured_at", measuredAt) + .put("bodyweight", row.optDouble("weight", row.optDouble("bodyweight", Double.NaN)).takeIf { it.isFinite() } ?: JSONObject.NULL) + .put("waist", JSONObject.NULL) + .put("chest", JSONObject.NULL) + .put("arm", JSONObject.NULL) + .put("thigh", JSONObject.NULL) + .put("notes", row.optString("notes")) + .put("created_at", measuredAt) + .put("updated_at", measuredAt), + ) + } + (snapshot.optJSONArray("bodyMeasurements") ?: JSONArray()).forEachObject { row -> + bodyCounter++ + val measuredAt = parseImportEpochMillis( + row.opt("measuredAt").takeUnless { it == null || it == JSONObject.NULL } ?: row.opt("date"), + now + bodyCounter, + ) + data.getJSONArray("body_measurements").put( + JSONObject() + .put("id", stableId("body_measure", row.optString("id"), bodyCounter)) + .put("measured_at", measuredAt) + .put("bodyweight", row.optDouble("bodyweight", Double.NaN).takeIf { it.isFinite() } ?: JSONObject.NULL) + .put("waist", row.optDouble("waist", Double.NaN).takeIf { it.isFinite() } ?: JSONObject.NULL) + .put("chest", row.optDouble("chest", Double.NaN).takeIf { it.isFinite() } ?: JSONObject.NULL) + .put("arm", row.optDouble("arm", Double.NaN).takeIf { it.isFinite() } ?: JSONObject.NULL) + .put("thigh", row.optDouble("thigh", Double.NaN).takeIf { it.isFinite() } ?: JSONObject.NULL) + .put("notes", row.optString("notes")) + .put("created_at", measuredAt) + .put("updated_at", measuredAt), + ) + } + + appState.keys().forEach { key -> + val value = appState.opt(key) + val type = when (value) { + is Boolean -> "boolean" + is Number -> "number" + is String -> "string" + else -> "json" + } + data.getJSONArray("app_settings").put( + JSONObject() + .put("id", "setting_$key") + .put("key", key) + .put("value", if (type == "json") JSONObject.wrap(value)?.toString().orEmpty() else value?.toString().orEmpty()) + .put("value_type", type) + .put("updated_at", now), + ) + } + + return JSONObject() + .put("version", EXPORT_VERSION) + .put("type", EXPORT_TYPE) + .put("exportedAt", java.time.Instant.now().toString()) + .put("data", data) + } + + private fun extractLegacySnapshot(raw: JSONObject): Pair { + val schema = raw.optString("schema") + val isSqlite = schema.startsWith("IRONLOG_SQLITE_EXPORT_V") && raw.optJSONObject("payload") != null + if (isSqlite) { + return Pair(raw.optJSONObject("payload") ?: JSONObject(), raw.optJSONObject("appState") ?: JSONObject()) + } + val root = if (raw.optJSONObject("payload") != null) raw.optJSONObject("payload")!! else raw + val snapshot = JSONObject() + .put("plans", legacyArray(root, raw, "plans", "ironlog_plans")) + .put("history", legacyArray(root, raw, "history", "ironlog_history")) + .put("bodyWeight", legacyArray(root, raw, "bodyWeight", "ironlog_bw")) + .put("bodyMeasurements", legacyArray(root, raw, "bodyMeasurements", "@ironlog/bodyMeasurements")) + .put("customExercises", legacyArray(root, raw, "customExercises", "@ironlog/customExercises")) + val appState = when { + raw.optJSONObject("appState") != null -> raw.optJSONObject("appState")!! + root.optJSONObject("settings") != null -> root.optJSONObject("settings")!! + else -> legacyDomainObject(raw, "ironlog_settings") + } + return Pair(snapshot, appState) + } + + private fun legacyArray(root: JSONObject, raw: JSONObject, rootKey: String, domainKey: String): JSONArray { + val direct = root.opt(rootKey) + if (direct is JSONArray) return direct + if (direct is String) runCatching { JSONArray(direct) }.getOrNull()?.let { return it } + return legacyDomainItem(raw, domainKey) ?: JSONArray() + } + + private fun legacyDomainObject(raw: JSONObject, key: String): JSONObject { + val domains = raw.optJSONObject("domains") ?: return JSONObject() + val names = domains.keys() + while (names.hasNext()) { + val d = domains.optJSONObject(names.next()) ?: continue + val items = d.optJSONObject("items") ?: continue + val v = items.opt(key) ?: continue + if (v is JSONObject) return v + if (v is String) { + runCatching { JSONObject(v) }.getOrNull()?.let { return it } + } + } + return JSONObject() + } + + private fun legacyDomainItem(raw: JSONObject, key: String): JSONArray? { + val domains = raw.optJSONObject("domains") ?: return null + val names = domains.keys() + while (names.hasNext()) { + val d = domains.optJSONObject(names.next()) ?: continue + val items = d.optJSONObject("items") ?: continue + val v = items.opt(key) ?: continue + if (v is JSONArray) return v + if (v is String) { + runCatching { JSONArray(v) }.getOrNull()?.let { return it } + } + } + return null + } + + private fun clearUserDataTables(clearExercises: Boolean) { + ObjectBox.store.boxFor(WorkoutSetEntity::class.java).removeAll() + ObjectBox.store.boxFor(WorkoutExerciseEntity::class.java).removeAll() + ObjectBox.store.boxFor(WorkoutEntity::class.java).removeAll() + ObjectBox.store.boxFor(PlanExerciseEntity::class.java).removeAll() + ObjectBox.store.boxFor(PlanDayEntity::class.java).removeAll() + ObjectBox.store.boxFor(PlanEntity::class.java).removeAll() + if (clearExercises) { + ObjectBox.store.boxFor(ExerciseMuscleEntity::class.java).removeAll() + ObjectBox.store.boxFor(ExerciseEntity::class.java).removeAll() + } + ObjectBox.store.boxFor(BodyMeasurementEntity::class.java).removeAll() + ObjectBox.store.boxFor(ProgressPhotoEntity::class.java).removeAll() + ObjectBox.store.boxFor(AppSettingEntity::class.java).removeAll() + ObjectBox.store.boxFor(AthleteCalibrationEntity::class.java).removeAll() + ObjectBox.store.boxFor(GamificationProfileEntity::class.java).removeAll() + ObjectBox.store.boxFor(IronLedgerEventEntity::class.java).removeAll() + } + + private fun importData(data: JSONObject, mergeSingletons: Boolean) { + val exercisesBox = ObjectBox.store.boxFor(ExerciseEntity::class.java) + val exerciseMusclesBox = ObjectBox.store.boxFor(ExerciseMuscleEntity::class.java) + val plansBox = ObjectBox.store.boxFor(PlanEntity::class.java) + val planDaysBox = ObjectBox.store.boxFor(PlanDayEntity::class.java) + val planExercisesBox = ObjectBox.store.boxFor(PlanExerciseEntity::class.java) + val workoutsBox = ObjectBox.store.boxFor(WorkoutEntity::class.java) + val workoutExercisesBox = ObjectBox.store.boxFor(WorkoutExerciseEntity::class.java) + val workoutSetsBox = ObjectBox.store.boxFor(WorkoutSetEntity::class.java) + val bodyBox = ObjectBox.store.boxFor(BodyMeasurementEntity::class.java) + val photosBox = ObjectBox.store.boxFor(ProgressPhotoEntity::class.java) + val settingsBox = ObjectBox.store.boxFor(AppSettingEntity::class.java) + val athleteCalibrationsBox = ObjectBox.store.boxFor(AthleteCalibrationEntity::class.java) + val gamificationProfilesBox = ObjectBox.store.boxFor(GamificationProfileEntity::class.java) + val ironLedgerEventsBox = ObjectBox.store.boxFor(IronLedgerEventEntity::class.java) + + val planByUid = mutableMapOf() + val dayByUid = mutableMapOf() + val exerciseByUid = mutableMapOf() + val workoutByUid = mutableMapOf() + val workoutExerciseByUid = mutableMapOf() + + (data.optJSONArray("exercises") ?: JSONArray()).forEachObject { o -> + val uid = o.optString("id").trim() + if (uid.isBlank()) return@forEachObject + val row = ExerciseEntity().apply { + this.uid = uid + name = o.optString("name") + normalizedName = o.optString("normalized_name") + primaryMuscle = o.optString("primary_muscle") + equipment = o.optString("equipment") + category = o.optString("category", "strength") + isCustom = o.optBoolean("is_custom", false) + source = o.optString("source") + notes = o.optString("notes") + equipmentDetail = o.optNullableString("equipment_detail") + apparatusJson = o.optNullableString("apparatus_json") + trackingType = o.optNullableString("tracking_type") + isBodyweight = o.optBoolean("is_bodyweight", equipment.equals("bodyweight", true)) + requiresExternalLoad = o.optBoolean("requires_external_load", false) + movementPattern = o.optNullableString("movement_pattern") + difficulty = o.optNullableString("difficulty") + aliasesJson = o.optNullableString("aliases_json") + sourceTagsJson = o.optNullableString("source_tags_json") + secondaryMusclesJson = o.optNullableString("secondary_muscles_json") + createdAt = o.optLong("created_at", 0L) + updatedAt = o.optLong("updated_at", createdAt) + } + exercisesBox.query(ExerciseEntity_.uid.equal(uid)).build().use { q -> + q.findFirst()?.let { row.objectBoxId = it.objectBoxId } + } + exercisesBox.put(row) + exerciseByUid[row.uid] = row + } + + (data.optJSONArray("exercise_muscles") ?: JSONArray()).forEachObject { o -> + val uid = o.optString("id").trim() + val exerciseUid = o.optString("exercise_id").trim() + if (uid.isBlank() || exerciseUid.isBlank()) return@forEachObject + val row = ExerciseMuscleEntity().apply { + this.uid = uid + this.exerciseUid = exerciseUid + exerciseByUid[exerciseUid]?.let { exercise.target = it } + muscle = o.optString("muscle") + role = o.optString("role", "secondary") + contributionFraction = o.optDouble("contribution_fraction", 0.0) + createdAt = o.optLong("created_at", 0L) + updatedAt = o.optLong("updated_at", createdAt) + } + exerciseMusclesBox.query(ExerciseMuscleEntity_.uid.equal(uid)).build().use { q -> + q.findFirst()?.let { row.objectBoxId = it.objectBoxId } + } + exerciseMusclesBox.put(row) + } + + (data.optJSONArray("plans") ?: JSONArray()).forEachObject { o -> + val uid = o.optString("id").trim() + if (uid.isBlank()) return@forEachObject + val row = PlanEntity().apply { + this.uid = uid + name = o.optString("name") + goal = o.optString("goal") + description = o.optString("description") + isActive = o.optBoolean("is_active", false) + createdAt = o.optLong("created_at", 0L) + updatedAt = o.optLong("updated_at", createdAt) + } + plansBox.query(PlanEntity_.uid.equal(uid)).build().use { q -> + q.findFirst()?.let { row.objectBoxId = it.objectBoxId } + } + plansBox.put(row) + planByUid[row.uid] = row + } + + (data.optJSONArray("plan_days") ?: JSONArray()).forEachObject { o -> + val uid = o.optString("id").trim() + if (uid.isBlank()) return@forEachObject + val planUid = o.optString("plan_id") + val row = PlanDayEntity().apply { + this.uid = uid + this.planUid = planUid + planByUid[planUid]?.let { plan.target = it } + name = o.optString("name") + color = o.optString("color", "#FF4500") + orderIndex = o.optInt("order_index", 0) + createdAt = o.optLong("created_at", 0L) + updatedAt = o.optLong("updated_at", createdAt) + } + planDaysBox.query(PlanDayEntity_.uid.equal(uid)).build().use { q -> + q.findFirst()?.let { row.objectBoxId = it.objectBoxId } + } + planDaysBox.put(row) + dayByUid[row.uid] = row + } + + (data.optJSONArray("plan_exercises") ?: JSONArray()).forEachObject { o -> + val uid = o.optString("id").trim() + if (uid.isBlank()) return@forEachObject + val dayUid = o.optString("plan_day_id") + val exerciseUid = o.optString("exercise_id") + val row = PlanExerciseEntity().apply { + this.uid = uid + this.planDayUid = dayUid + dayByUid[dayUid]?.let { planDay.target = it } + this.exerciseUid = exerciseUid + exerciseByUid[exerciseUid]?.let { exercise.target = it } + orderIndex = o.optInt("order_index", 0) + sets = max(1, o.optInt("sets", 1)) + reps = o.optString("reps") + restSeconds = max(0, o.optInt("rest_seconds", 0)) + supersetGroup = o.optString("superset_group") + isWarmup = o.optBoolean("is_warmup", false) + notes = o.optString("notes") + createdAt = o.optLong("created_at", 0L) + updatedAt = o.optLong("updated_at", createdAt) + } + planExercisesBox.query(PlanExerciseEntity_.uid.equal(uid)).build().use { q -> + q.findFirst()?.let { row.objectBoxId = it.objectBoxId } + } + planExercisesBox.put(row) + } + + (data.optJSONArray("workouts") ?: JSONArray()).forEachObject { o -> + val uid = o.optString("id").trim() + if (uid.isBlank()) return@forEachObject + val planUid = o.optString("plan_id").ifBlank { null } + val dayUid = o.optString("plan_day_id").ifBlank { null } + val row = WorkoutEntity().apply { + this.uid = uid + this.planUid = planUid + planUid?.let { pu -> planByUid[pu]?.let { plan.target = it } } + this.planDayUid = dayUid + dayUid?.let { du -> dayByUid[du]?.let { planDay.target = it } } + name = o.optString("name") + startedAt = o.optLong("started_at", 0L) + completedAt = if (o.isNull("completed_at")) null else o.optLong("completed_at") + durationSeconds = o.optInt("duration_seconds", 0) + rating = if (o.isNull("rating")) null else o.optDouble("rating") + notes = o.optString("notes") + status = o.optString("status", "completed") + // New IronLog backups preserve provenance. Older/foreign payloads lack the + // field and are conservatively treated as imported proof. + imported = o.optBoolean("imported", true) + createdAt = o.optLong("created_at", startedAt) + updatedAt = o.optLong("updated_at", createdAt) + } + workoutsBox.query(WorkoutEntity_.uid.equal(uid)).build().use { q -> + q.findFirst()?.let { row.objectBoxId = it.objectBoxId } + } + workoutsBox.put(row) + workoutByUid[row.uid] = row + } + + (data.optJSONArray("workout_exercises") ?: JSONArray()).forEachObject { o -> + val uid = o.optString("id").trim() + if (uid.isBlank()) return@forEachObject + val workoutUid = o.optString("workout_id") + val exerciseUid = o.optString("exercise_id") + val row = WorkoutExerciseEntity().apply { + this.uid = uid + this.workoutUid = workoutUid + workoutByUid[workoutUid]?.let { workout.target = it } + this.exerciseUid = exerciseUid + exerciseByUid[exerciseUid]?.let { exercise.target = it } + orderIndex = o.optInt("order_index", 0) + supersetGroup = o.optString("superset_group") + notes = o.optString("notes") + createdAt = o.optLong("created_at", 0L) + updatedAt = o.optLong("updated_at", createdAt) + } + workoutExercisesBox.query(WorkoutExerciseEntity_.uid.equal(uid)).build().use { q -> + q.findFirst()?.let { row.objectBoxId = it.objectBoxId } + } + workoutExercisesBox.put(row) + workoutExerciseByUid[row.uid] = row + } + + (data.optJSONArray("workout_sets") ?: JSONArray()).forEachObject { o -> + val uid = o.optString("id").trim() + if (uid.isBlank()) return@forEachObject + val workoutExerciseUid = o.optString("workout_exercise_id") + val row = WorkoutSetEntity().apply { + this.uid = uid + this.workoutExerciseUid = workoutExerciseUid + workoutExerciseByUid[workoutExerciseUid]?.let { workoutExercise.target = it } + setIndex = o.optInt("set_index", 1) + weight = o.optDouble("weight", 0.0) + reps = o.optDouble("reps", 0.0) + rpe = if (o.isNull("rpe")) null else o.optDouble("rpe") + rir = if (o.isNull("rir")) null else o.optDouble("rir") + restSeconds = o.optInt("rest_seconds", 0) + isWarmup = o.optBoolean("is_warmup", false) + isDropset = o.optBoolean("is_dropset", false) + isAmrap = o.optBoolean("is_amrap", false) + toFailure = o.optBoolean("to_failure", false) + completedAt = if (o.isNull("completed_at")) null else o.optLong("completed_at") + createdAt = o.optLong("created_at", 0L) + updatedAt = o.optLong("updated_at", createdAt) + } + workoutSetsBox.query(WorkoutSetEntity_.uid.equal(uid)).build().use { q -> + q.findFirst()?.let { row.objectBoxId = it.objectBoxId } + } + workoutSetsBox.put(row) + } + + (data.optJSONArray("body_measurements") ?: JSONArray()).forEachObject { o -> + val uid = o.optString("id").trim() + if (uid.isBlank()) return@forEachObject + val row = BodyMeasurementEntity().apply { + this.uid = uid + measuredAt = o.optLong("measured_at", 0L) + bodyweight = if (o.isNull("bodyweight")) null else o.optDouble("bodyweight") + waist = if (o.isNull("waist")) null else o.optDouble("waist") + chest = if (o.isNull("chest")) null else o.optDouble("chest") + arm = if (o.isNull("arm")) null else o.optDouble("arm") + thigh = if (o.isNull("thigh")) null else o.optDouble("thigh") + notes = o.optString("notes") + createdAt = o.optLong("created_at", measuredAt) + updatedAt = o.optLong("updated_at", createdAt) + } + bodyBox.query(BodyMeasurementEntity_.uid.equal(uid)).build().use { q -> + q.findFirst()?.let { row.objectBoxId = it.objectBoxId } + } + bodyBox.put(row) + } + + (data.optJSONArray("progress_photos") ?: JSONArray()).forEachObject { o -> + val uid = o.optString("id").trim() + if (uid.isBlank()) return@forEachObject + val row = ProgressPhotoEntity().apply { + this.uid = uid + fileUri = o.optString("file_uri") + takenAt = o.optLong("taken_at", 0L) + bodyweight = if (o.isNull("bodyweight")) null else o.optDouble("bodyweight") + notes = o.optString("notes") + createdAt = o.optLong("created_at", takenAt) + updatedAt = o.optLong("updated_at", createdAt) + } + photosBox.query(ProgressPhotoEntity_.uid.equal(uid)).build().use { q -> + q.findFirst()?.let { row.objectBoxId = it.objectBoxId } + } + photosBox.put(row) + } + + (data.optJSONArray("app_settings") ?: JSONArray()).forEachObject { o -> + val key = o.optString("key").trim() + if (key.isBlank()) return@forEachObject + val row = AppSettingEntity().apply { + this.key = key + value = o.optString("value") + valueType = o.optString("value_type", "string") + updatedAt = o.optLong("updated_at", 0L) + } + settingsBox.query(AppSettingEntity_.key.equal(key)).build().use { q -> + q.findFirst()?.let { row.objectBoxId = it.objectBoxId } + } + settingsBox.put(row) + } + + (data.optJSONArray("athlete_calibrations") ?: JSONArray()).forEachObject { o -> + val offlineUserId = "local" + val row = AthleteCalibrationEntity().apply { + this.offlineUserId = offlineUserId + trainingAgeMonths = o.optInt("training_age_months", 0) + historicalTrainingDaysPerWeek = o.optInt("historical_training_days_per_week", 3).coerceIn(1, 7) + firstVerifiedSessionAt = o.optLong("first_verified_session_at", 0L) + weightUnit = o.optString("weight_unit", "kg") + bodyweightKg = if (o.isNull("bodyweight_kg")) null else o.optDouble("bodyweight_kg") + goalMode = o.optString("goal_mode", "hypertrophy") + weeklyGoalDays = o.optInt("weekly_goal_days", 4) + importedHistory = o.optBoolean("imported_history", false) + confidence = o.optDouble("confidence", 0.5) + updatedAt = o.optLong("updated_at", 0L) + hasPastTraining = o.optBoolean("has_past_training", false) + hasGymAccess = o.optBoolean("has_gym_access", true) + baselinePushups = o.optInt("baseline_pushups", 0) + baselinePullups = o.optInt("baseline_pullups", 0) + baselineBenchKg = o.optInt("baseline_bench_kg", 0) + baselineLatPulldownKg = o.optInt("baseline_lat_pulldown_kg", 0) + baselineMileRunSeconds = o.optInt("baseline_mile_run_seconds", 0) + } + athleteCalibrationsBox.query(AthleteCalibrationEntity_.offlineUserId.equal(offlineUserId)).build().use { q -> + q.findFirst()?.let { existing -> + row.id = existing.id + if (mergeSingletons) { + row.trainingAgeMonths = maxOf(row.trainingAgeMonths, existing.trainingAgeMonths) + row.historicalTrainingDaysPerWeek = existing.historicalTrainingDaysPerWeek.takeIf { it in 1..7 } + ?: row.historicalTrainingDaysPerWeek + row.firstVerifiedSessionAt = listOf(row.firstVerifiedSessionAt, existing.firstVerifiedSessionAt) + .filter { it > 0L }.minOrNull() ?: 0L + row.weightUnit = existing.weightUnit + row.bodyweightKg = existing.bodyweightKg ?: row.bodyweightKg + row.goalMode = existing.goalMode + row.weeklyGoalDays = existing.weeklyGoalDays + row.importedHistory = row.importedHistory || existing.importedHistory + row.confidence = maxOf(row.confidence, existing.confidence) + row.updatedAt = maxOf(row.updatedAt, existing.updatedAt) + row.hasPastTraining = row.hasPastTraining || existing.hasPastTraining + row.hasGymAccess = existing.hasGymAccess + row.baselinePushups = maxOf(row.baselinePushups, existing.baselinePushups) + row.baselinePullups = maxOf(row.baselinePullups, existing.baselinePullups) + row.baselineBenchKg = maxOf(row.baselineBenchKg, existing.baselineBenchKg) + row.baselineLatPulldownKg = maxOf(row.baselineLatPulldownKg, existing.baselineLatPulldownKg) + row.baselineMileRunSeconds = maxOf(row.baselineMileRunSeconds, existing.baselineMileRunSeconds) + } + } + } + athleteCalibrationsBox.put(row) + } + + (data.optJSONArray("gamification_profiles") ?: JSONArray()).forEachObject { o -> + val offlineUserId = "local" + val row = GamificationProfileEntity().apply { + this.offlineUserId = offlineUserId + totalXp = o.optLong("total_xp", 0L) + level = o.optInt("level", 1) + xpInLevel = o.optLong("xp_in_level", 0L) + rank = o.optString("rank", "E") + activeTitle = o.optString("active_title", "Ledger Initiate") + statsJson = o.optString("stats_json", "{}") + weeklyXp = o.optInt("weekly_xp", 0) + weeklyXpResetWeek = o.optString("weekly_xp_reset_week") + streakWeeks = o.optInt("streak_weeks", 0) + makeupCompletionsJson = o.optString( + "recovery_circuit_completions_json", + o.optString("makeup_completions_json", "{}"), + ) + unlockedBadges = o.optString("unlocked_badges") + } + gamificationProfilesBox.query(GamificationProfileEntity_.offlineUserId.equal(offlineUserId)).build().use { q -> + q.findFirst()?.let { existing -> + row.id = existing.id + if (mergeSingletons) { + val importedWins = row.totalXp >= existing.totalXp + row.totalXp = maxOf(row.totalXp, existing.totalXp) + row.level = maxOf(row.level, existing.level) + if (!importedWins) { + row.xpInLevel = existing.xpInLevel + row.rank = existing.rank + row.activeTitle = existing.activeTitle + row.statsJson = existing.statsJson + } + row.weeklyXp = maxOf(row.weeklyXp, existing.weeklyXp) + if (existing.weeklyXpResetWeek > row.weeklyXpResetWeek) { + row.weeklyXpResetWeek = existing.weeklyXpResetWeek + } + row.streakWeeks = maxOf(row.streakWeeks, existing.streakWeeks) + row.makeupCompletionsJson = mergeIntJsonMaps( + existing.makeupCompletionsJson, + row.makeupCompletionsJson, + ) + row.unlockedBadges = (existing.unlockedBadges.split(',') + row.unlockedBadges.split(',')) + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + .joinToString(",") + } + } + } + gamificationProfilesBox.put(row) + } + + (data.optJSONArray("iron_ledger_events") ?: JSONArray()).forEachObject { o -> + val eventId = o.optString("event_id").trim() + if (eventId.isBlank()) return@forEachObject + val row = IronLedgerEventEntity().apply { + this.eventId = eventId + sourceType = o.optString("source_type") + sourceId = o.optString("source_id") + eventKind = o.optString("event_kind") + occurredAt = o.optLong("occurred_at", 0L) + xpDelta = o.optInt("xp_delta", 0) + statDeltasJson = o.optString("stat_deltas_json", "{}") + trustScore = o.optDouble("trust_score", 1.0) + fingerprint = o.optString("fingerprint") + metadataJson = o.optString("metadata_json", "{}") + invalidated = o.optBoolean("invalidated", false) + } + ironLedgerEventsBox.query(IronLedgerEventEntity_.eventId.equal(eventId)).build().use { q -> + q.findFirst()?.let { row.id = it.id } + } + ironLedgerEventsBox.put(row) + } + + // keep seed flag for first app open consistency after restore + ObjectBox.store.boxFor(AppSettingEntity::class.java).query(AppSettingEntity_.key.equal("exercise_seed_complete")).build().use { q -> + if (q.findFirst() == null) { + ObjectBox.store.boxFor(AppSettingEntity::class.java).put(AppSettingEntity().apply { + key = "exercise_seed_complete" + value = "true" + valueType = "boolean" + updatedAt = System.currentTimeMillis() + }) + } + } + } +} + +internal fun backfillMissingAthleteStateRows( + data: JSONObject, + nowMs: Long = System.currentTimeMillis(), +) { + val settingsRows = data.optJSONArray("app_settings") ?: JSONArray() + val calibrationRows = data.optJSONArray("athlete_calibrations") ?: JSONArray().also { data.put("athlete_calibrations", it) } + val profileRows = data.optJSONArray("gamification_profiles") ?: JSONArray().also { data.put("gamification_profiles", it) } + if (calibrationRows.length() > 0 && profileRows.length() > 0) return + + val settings = linkedMapOf>() + for (i in 0 until settingsRows.length()) { + val row = settingsRows.optJSONObject(i) ?: continue + val key = row.optString("key").trim() + if (key.isBlank()) continue + settings[key] = row.optString("value") to row.optString("value_type", "string") + } + fun stringSetting(key: String): String? = settings[key]?.first?.takeIf { it.isNotBlank() } + + if (calibrationRows.length() == 0) { + val calibration = deriveFallbackAthleteCalibration(settings, data.optJSONArray("workouts")?.length()?.let { it > 0 } == true, nowMs) + calibrationRows.put( + JSONObject() + .put("offline_user_id", calibration.offlineUserId) + .put("training_age_months", calibration.trainingAgeMonths) + .put("historical_training_days_per_week", calibration.historicalTrainingDaysPerWeek) + .put("first_verified_session_at", calibration.firstVerifiedSessionAt) + .put("weight_unit", calibration.weightUnit) + .put("bodyweight_kg", calibration.bodyweightKg ?: JSONObject.NULL) + .put("goal_mode", calibration.goalMode) + .put("weekly_goal_days", calibration.weeklyGoalDays) + .put("imported_history", calibration.importedHistory) + .put("confidence", calibration.confidence) + .put("updated_at", calibration.updatedAt) + .put("has_past_training", calibration.hasPastTraining) + .put("has_gym_access", calibration.hasGymAccess) + .put("baseline_pushups", calibration.baselinePushups) + .put("baseline_pullups", calibration.baselinePullups) + .put("baseline_bench_kg", calibration.baselineBenchKg) + .put("baseline_lat_pulldown_kg", calibration.baselineLatPulldownKg) + .put("baseline_mile_run_seconds", calibration.baselineMileRunSeconds) + ) + } + if (profileRows.length() == 0) { + val profile = fallbackGamificationProfileRow() + profileRows.put( + JSONObject() + .put("offline_user_id", profile.offlineUserId) + .put("total_xp", profile.totalXp) + .put("level", profile.level) + .put("xp_in_level", profile.xpInLevel) + .put("rank", profile.rank) + .put("active_title", profile.activeTitle) + .put("stats_json", profile.statsJson) + .put("weekly_xp", profile.weeklyXp) + .put("weekly_xp_reset_week", profile.weeklyXpResetWeek) + .put("streak_weeks", profile.streakWeeks) + .put("recovery_circuit_completions_json", profile.recoveryCircuitCompletionsJson) + .put("unlocked_badges", profile.unlockedBadges) + ) + } +} + +internal data class FallbackAthleteCalibrationRow( + val offlineUserId: String = "local", + val trainingAgeMonths: Int, + val historicalTrainingDaysPerWeek: Int, + val firstVerifiedSessionAt: Long, + val weightUnit: String, + val bodyweightKg: Double?, + val goalMode: String, + val weeklyGoalDays: Int, + val importedHistory: Boolean, + val confidence: Double, + val updatedAt: Long, + val hasPastTraining: Boolean, + val hasGymAccess: Boolean, + val baselinePushups: Int, + val baselinePullups: Int, + val baselineBenchKg: Int, + val baselineLatPulldownKg: Int, + val baselineMileRunSeconds: Int, +) + +internal data class FallbackGamificationProfileRow( + val offlineUserId: String = "local", + val totalXp: Long = 0L, + val level: Int = 1, + val xpInLevel: Long = 0L, + val rank: String = "E", + val activeTitle: String = "Ledger Initiate", + val statsJson: String = "{}", + val weeklyXp: Int = 0, + val weeklyXpResetWeek: String = "", + val streakWeeks: Int = 0, + val recoveryCircuitCompletionsJson: String = "{}", + val unlockedBadges: String = "", +) + +internal fun deriveFallbackAthleteCalibration( + settings: Map>, + hasImportedWorkouts: Boolean, + nowMs: Long = System.currentTimeMillis(), +): FallbackAthleteCalibrationRow { + fun stringSetting(key: String): String? = settings[key]?.first?.takeIf { it.isNotBlank() } + fun intSetting(key: String, default: Int = 0): Int = stringSetting(key)?.toIntOrNull() ?: default + fun longSetting(key: String, default: Long = 0L): Long = stringSetting(key)?.toLongOrNull() ?: default + fun doubleSetting(key: String): Double? = stringSetting(key)?.toDoubleOrNull() + fun booleanSetting(key: String, default: Boolean = false): Boolean = stringSetting(key)?.equals("true", ignoreCase = true) ?: default + + return FallbackAthleteCalibrationRow( + trainingAgeMonths = intSetting("baseline_training_age_months"), + historicalTrainingDaysPerWeek = intSetting("baseline_historical_training_days_per_week", 3).coerceIn(1, 7), + firstVerifiedSessionAt = longSetting("first_verified_session_at", 0L), + weightUnit = stringSetting("weightUnit") ?: "kg", + bodyweightKg = doubleSetting("baseline_bodyweight_kg"), + goalMode = stringSetting("goalMode") ?: "hypertrophy", + weeklyGoalDays = intSetting("weeklyGoalDays", 4).coerceIn(1, 7), + importedHistory = booleanSetting("ledger_imported_history", hasImportedWorkouts), + confidence = 0.5, + updatedAt = nowMs, + hasPastTraining = booleanSetting("baseline_has_past_training"), + hasGymAccess = booleanSetting("baseline_has_gym_access", true), + baselinePushups = intSetting("baseline_pushups"), + baselinePullups = intSetting("baseline_pullups"), + baselineBenchKg = intSetting("baseline_bench_kg"), + baselineLatPulldownKg = intSetting("baseline_lat_pulldown_kg"), + baselineMileRunSeconds = intSetting("baseline_mile_run_seconds"), + ) +} + +internal fun fallbackGamificationProfileRow(): FallbackGamificationProfileRow = FallbackGamificationProfileRow() + +private fun JSONArray.forEachObject(block: (JSONObject) -> Unit) { + for (i in 0 until length()) { + val o = optJSONObject(i) ?: continue + block(o) + } +} + +private fun JSONObject.optNullableString(key: String): String? = + if (!has(key) || isNull(key)) null else optString(key).takeIf { it.isNotBlank() } + +private fun mergeIntJsonMaps(first: String, second: String): String { + val merged = linkedMapOf() + listOf(first, second).forEach { raw -> + val json = runCatching { JSONObject(raw) }.getOrNull() ?: return@forEach + json.keys().forEach { key -> + merged[key] = maxOf(merged[key] ?: 0, json.optInt(key, 0)) + } + } + return JSONObject(merged as Map<*, *>).toString() +} diff --git a/app/src/main/java/com/ironlog/app/data/repository/ObjectBoxFlow.kt b/app/src/main/java/com/ironlog/app/data/repository/ObjectBoxFlow.kt new file mode 100644 index 0000000..90369f2 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/repository/ObjectBoxFlow.kt @@ -0,0 +1,14 @@ +package com.ironlog.app.data.repository + +import io.objectbox.query.Query +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +fun Query.asFlow(): Flow> = callbackFlow { + val subscription = subscribe().observer { data -> + trySend(data) + Unit + } + awaitClose { subscription.cancel() } +} diff --git a/app/src/main/java/com/ironlog/app/data/repository/PlanRepository.kt b/app/src/main/java/com/ironlog/app/data/repository/PlanRepository.kt new file mode 100644 index 0000000..c4030f5 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/repository/PlanRepository.kt @@ -0,0 +1,413 @@ +package com.ironlog.app.data.repository + +import com.ironlog.app.data.model.FullPlanObject +import com.ironlog.app.data.model.PlanBundle +import com.ironlog.app.data.model.PlanDayInput +import com.ironlog.app.data.model.PlanExerciseInput +import com.ironlog.app.data.model.PlanInput +import com.ironlog.app.data.model.PlanSnapshot +import com.ironlog.app.data.model.WorkoutPerformedExercise +import com.ironlog.app.data.objectbox.ExerciseEntity +import com.ironlog.app.data.objectbox.ExerciseEntity_ +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.data.objectbox.PlanDayEntity +import com.ironlog.app.data.objectbox.PlanDayEntity_ +import com.ironlog.app.data.objectbox.PlanEntity +import com.ironlog.app.data.objectbox.PlanEntity_ +import com.ironlog.app.data.objectbox.PlanExerciseEntity +import com.ironlog.app.data.objectbox.PlanExerciseEntity_ +import com.ironlog.app.util.requireNonEmpty +import com.ironlog.app.util.requireNumberMin +import com.ironlog.app.util.FuzzyExerciseMapper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlin.math.max +import kotlin.math.roundToInt + +class PlanRepository { + private val plansBox get() = ObjectBox.store.boxFor(PlanEntity::class.java) + private val daysBox get() = ObjectBox.store.boxFor(PlanDayEntity::class.java) + private val planExercisesBox get() = ObjectBox.store.boxFor(PlanExerciseEntity::class.java) + private val exercisesBox get() = ObjectBox.store.boxFor(ExerciseEntity::class.java) + + suspend fun createPlan(input: PlanInput): PlanEntity = withContext(Dispatchers.IO) { + requireNonEmpty(input.name, "name") + requireNonEmpty(input.goal, "goal") + val now = System.currentTimeMillis() + // Auto-activate the plan if it's the first one, or if the caller says so. + val noActivePlanExists = plansBox.query(PlanEntity_.isActive.equal(true)).build().use { it.count() == 0L } + val created = PlanEntity().apply { + name = input.name!!.trim() + goal = input.goal!!.trim() + description = input.description?.toString() ?: "" + isActive = input.isActive == true || noActivePlanExists + createdAt = now + updatedAt = now + } + plansBox.put(created) + created + } + + suspend fun updatePlan(planId: String, input: PlanInput): PlanEntity = withContext(Dispatchers.IO) { + requireNonEmpty(planId, "planId") + val plan = findPlanByUidOrThrow(planId) + if (!input.name.isNullOrEmpty()) plan.name = input.name.trim() + if (!input.goal.isNullOrEmpty()) plan.goal = input.goal.trim() + if (input.description != null) plan.description = input.description.ifEmpty { "" } + if (input.isActive != null) plan.isActive = input.isActive + plan.updatedAt = System.currentTimeMillis() + plansBox.put(plan) + plan + } + + suspend fun deletePlan(planId: String) = withContext(Dispatchers.IO) { + requireNonEmpty(planId, "planId") + val plan = findPlanByUidOrThrow(planId) + val days = getPlanDaysSnapshot(planId) + val dayIds = days.map { it.uid } + val exercises = if (dayIds.isEmpty()) emptyList() else { + planExercisesBox.query(PlanExerciseEntity_.planDayUid.oneOf(dayIds.toTypedArray())) + .build().use { it.find() } + } + planExercisesBox.remove(exercises) + daysBox.remove(days) + plansBox.remove(plan) + Unit + } + + fun getPlansFlow(): Flow> = + plansBox.query().orderDesc(PlanEntity_.updatedAt).build().asFlow() + + suspend fun ensureActivePlanIfNeeded(): PlanEntity? = withContext(Dispatchers.IO) { + val plans = plansBox.query().orderDesc(PlanEntity_.updatedAt).build().use { it.find() } + if (plans.isEmpty()) return@withContext null + + val selected = plans.firstOrNull { it.isActive } ?: plans.first() + var changed = false + plans.forEach { plan -> + val shouldBeActive = plan.uid == selected.uid + if (plan.isActive != shouldBeActive) { + plan.isActive = shouldBeActive + changed = true + } + } + if (changed) plansBox.put(plans) + selected + } + + fun getPlanFlow(planId: String): Flow> { + requireNonEmpty(planId, "planId") + return plansBox.query(PlanEntity_.uid.equal(planId)).build().asFlow() + } + + fun getPlanDaysFlow(planId: String): Flow> { + requireNonEmpty(planId, "planId") + return daysBox.query(PlanDayEntity_.planUid.equal(planId)).order(PlanDayEntity_.orderIndex).build().asFlow() + } + + fun getPlanExercisesFlow(planDayId: String): Flow> { + requireNonEmpty(planDayId, "planDayId") + return planExercisesBox.query(PlanExerciseEntity_.planDayUid.equal(planDayId)).order(PlanExerciseEntity_.orderIndex).build().asFlow() + } + + fun getPlanBundleFlow(planId: String, activeDayId: String? = null): Flow { + requireNonEmpty(planId, "planId") + return combine(getPlanFlow(planId), getPlanDaysFlow(planId)) { planRows, days -> + val plan = planRows.firstOrNull() ?: error("Plan not found: $planId") + PlanBundle(plan, days, activeDayId ?: days.firstOrNull()?.uid) + } + } + + suspend fun getPlanById(planId: String): PlanSnapshot = withContext(Dispatchers.IO) { + requireNonEmpty(planId, "planId") + val plan = findPlanByUidOrThrow(planId) + val days = getPlanDaysSnapshot(planId) + val dayIds = days.map { it.uid }.toSet() + val exercises = if (dayIds.isEmpty()) emptyList() else { + planExercisesBox.query(PlanExerciseEntity_.planDayUid.oneOf(dayIds.toTypedArray())) + .order(PlanExerciseEntity_.orderIndex) + .build().use { it.find() } + } + PlanSnapshot(plan, days, exercises) + } + + suspend fun createPlanDay(planId: String, input: PlanDayInput): PlanDayEntity = withContext(Dispatchers.IO) { + requireNonEmpty(planId, "planId") + requireNonEmpty(input.name, "name") + val plan = findPlanByUidOrThrow(planId) + val now = System.currentTimeMillis() + val count = getPlanDaysSnapshot(planId).size + val created = PlanDayEntity().apply { + this.plan.target = plan + planUid = plan.uid + name = input.name!!.trim() + color = input.color ?: "#FF4500" + orderIndex = input.orderIndex ?: count + createdAt = now + updatedAt = now + } + daysBox.put(created) + created + } + + suspend fun updatePlanDay(dayId: String, input: PlanDayInput): PlanDayEntity = withContext(Dispatchers.IO) { + requireNonEmpty(dayId, "dayId") + val day = findDayByUidOrThrow(dayId) + if (!input.name.isNullOrEmpty()) day.name = input.name.trim() + if (!input.color.isNullOrEmpty()) day.color = input.color + if (input.orderIndex != null) day.orderIndex = input.orderIndex + day.updatedAt = System.currentTimeMillis() + daysBox.put(day) + day + } + + suspend fun deletePlanDay(dayId: String) = withContext(Dispatchers.IO) { + requireNonEmpty(dayId, "dayId") + val day = findDayByUidOrThrow(dayId) + val exercises = getPlanExercisesSnapshot(dayId) + planExercisesBox.remove(exercises) + daysBox.remove(day) + Unit + } + + suspend fun addExerciseToPlanDay(dayId: String, input: PlanExerciseInput): PlanExerciseEntity = withContext(Dispatchers.IO) { + requireNonEmpty(dayId, "dayId") + requireNonEmpty(input.exerciseId, "exerciseId") + requireNumberMin(input.sets ?: 1, 1.0, "sets") + requireNonEmpty(input.reps, "reps") + requireNumberMin(input.restSeconds ?: 0, 0.0, "restSeconds") + val day = findDayByUidOrThrow(dayId) + val exercise = findExerciseByUidOrThrow(input.exerciseId!!) + val now = System.currentTimeMillis() + val count = getPlanExercisesSnapshot(dayId).size + val created = PlanExerciseEntity().apply { + planDay.target = day + planDayUid = day.uid + this.exercise.target = exercise + exerciseUid = exercise.uid + orderIndex = input.orderIndex ?: count + sets = input.sets ?: 1 + reps = input.reps!!.trim() + restSeconds = input.restSeconds ?: 0 + supersetGroup = input.supersetGroup?.toString() ?: "" + isWarmup = input.isWarmup == true + notes = input.notes?.toString() ?: "" + createdAt = now + updatedAt = now + } + planExercisesBox.put(created) + created + } + + suspend fun updatePlanExercise(planExerciseId: String, input: PlanExerciseInput): PlanExerciseEntity = withContext(Dispatchers.IO) { + requireNonEmpty(planExerciseId, "planExerciseId") + val row = findPlanExerciseByUidOrThrow(planExerciseId) + if (!input.exerciseId.isNullOrEmpty()) { + val ex = findExerciseByUidOrThrow(input.exerciseId) + row.exercise.target = ex + row.exerciseUid = ex.uid + } + if (input.orderIndex != null) row.orderIndex = input.orderIndex + if (input.sets != null) row.sets = input.sets + if (input.reps != null) row.reps = input.reps + if (input.restSeconds != null) row.restSeconds = input.restSeconds + if (input.supersetGroup != null) row.supersetGroup = input.supersetGroup.ifEmpty { "" } + if (input.isWarmup != null) row.isWarmup = input.isWarmup + if (input.notes != null) row.notes = input.notes.ifEmpty { "" } + row.updatedAt = System.currentTimeMillis() + planExercisesBox.put(row) + row + } + + suspend fun removePlanExercise(planExerciseId: String) = withContext(Dispatchers.IO) { + requireNonEmpty(planExerciseId, "planExerciseId") + planExercisesBox.remove(findPlanExerciseByUidOrThrow(planExerciseId)) + Unit + } + + suspend fun syncPlanDayFromWorkout(dayId: String, exerciseData: List) = withContext(Dispatchers.IO) { + requireNonEmpty(dayId, "dayId") + val day = findDayByUidOrThrow(dayId) + val now = System.currentTimeMillis() + val existing = getPlanExercisesSnapshot(dayId) + planExercisesBox.remove(existing) + + val creates = mutableListOf() + var order = 0 + for (ex in exerciseData) { + val exerciseId = ex.exerciseId ?: continue + val exercise = findExerciseByUidOrNull(exerciseId) ?: continue + val workSets = ex.sets.orEmpty().filter { it.isWarmup != true && it.type != "warmup" } + val setCount = workSets.size.takeIf { it > 0 } ?: 1 + val avgReps = if (workSets.isNotEmpty()) { + (workSets.sumOf { it.reps ?: 0.0 } / workSets.size).roundToInt() + } else { + when (val pr = ex.prescribedReps) { + is Number -> pr.toInt() + is String -> pr.toIntOrNull() ?: 8 + else -> 8 + } + } + val restValues = workSets.map { (it.restSeconds ?: it.rest ?: 0) }.filter { it > 0 } + val avgRestSeconds = if (restValues.isNotEmpty()) max(0, restValues.average().roundToInt()) else 90 + creates += PlanExerciseEntity().apply { + planDay.target = day + planDayUid = day.uid + this.exercise.target = exercise + exerciseUid = exercise.uid + orderIndex = order + sets = setCount + reps = (avgReps.takeIf { it != 0 } ?: 8).toString() + restSeconds = avgRestSeconds + supersetGroup = "" + isWarmup = false + notes = "" + createdAt = now + updatedAt = now + } + order++ + } + if (creates.isNotEmpty()) planExercisesBox.put(creates) + } + + suspend fun reorderPlanExercises(dayId: String, orderedIds: List) = withContext(Dispatchers.IO) { + requireNonEmpty(dayId, "dayId") + val rowMap = getPlanExercisesSnapshot(dayId).associateBy { it.uid } + val now = System.currentTimeMillis() + val toUpdate = mutableListOf() + orderedIds.forEachIndexed { index, id -> + val row = rowMap[id] ?: return@forEachIndexed + row.orderIndex = index + row.updatedAt = now + toUpdate.add(row) + } + if (toUpdate.isNotEmpty()) planExercisesBox.put(toUpdate) + } + + suspend fun importFullPlan(planObject: FullPlanObject): PlanEntity = withContext(Dispatchers.IO) { + requireNonEmpty(planObject.name, "plan name") + val now = System.currentTimeMillis() + val mapper = FuzzyExerciseMapper(exercisesBox.all) + val shouldActivate = plansBox.query(PlanEntity_.isActive.equal(true)).build().use { it.count() == 0L } + + val resolvedDays = planObject.days.mapIndexed { index, day -> + val resolved = day.exercises.map { ex -> ex to resolveExerciseId(ex, mapper) } + Triple(day, index, resolved) + } + + val wmPlan = PlanEntity().apply { + name = planObject.name!!.trim() + goal = (planObject.goal ?: "General Fitness").trim() + description = (planObject.description ?: "").trim() + isActive = shouldActivate + createdAt = now + updatedAt = now + } + plansBox.put(wmPlan) + + for ((day, index, resolvedExercises) in resolvedDays) { + val wmDay = PlanDayEntity().apply { + plan.target = wmPlan + planUid = wmPlan.uid + name = (day.name ?: "Day ${index + 1}").trim() + color = day.color ?: "#FF4500" + orderIndex = index + createdAt = now + updatedAt = now + } + daysBox.put(wmDay) + var exOrder = 0 + for ((ex, resolvedId) in resolvedExercises) { + val exercise = resolvedId?.let(::findExerciseByUidOrNull) ?: continue + val row = PlanExerciseEntity().apply { + planDay.target = wmDay + planDayUid = wmDay.uid + this.exercise.target = exercise + exerciseUid = exercise.uid + orderIndex = exOrder + sets = ex.sets ?: 3 + reps = (ex.reps ?: "8-12").trim() + restSeconds = ex.restSeconds ?: 90 + supersetGroup = ex.supersetGroup ?: "" + isWarmup = ex.isWarmup == true + notes = ex.notes ?: "" + createdAt = now + updatedAt = now + } + planExercisesBox.put(row) + exOrder++ + } + } + wmPlan + } + + suspend fun reorderPlans(orderedIds: List) = withContext(Dispatchers.IO) { + val rowMap = plansBox.all.associateBy { it.uid } + val now = System.currentTimeMillis() + val toUpdate = mutableListOf() + orderedIds.forEachIndexed { index, id -> + val row = rowMap[id] ?: return@forEachIndexed + // updatedAt ordering: first item gets highest value so orderDesc puts it first. + row.updatedAt = now - (index * 1000L) + // Explicitly mark the top plan as active so that after a restart + // firstOrNull { it.isActive } reliably picks the right plan instead + // of relying on the fragile updatedAt ordering heuristic. + row.isActive = (index == 0) + toUpdate.add(row) + } + if (toUpdate.isNotEmpty()) plansBox.put(toUpdate) + } + + suspend fun replaceAllPlans(nextPlans: List = emptyList()) = withContext(Dispatchers.IO) { + val exerciseRows = planExercisesBox.all + val dayRows = daysBox.all + val planRows = plansBox.all + if (exerciseRows.isNotEmpty()) planExercisesBox.remove(exerciseRows) + if (dayRows.isNotEmpty()) daysBox.remove(dayRows) + if (planRows.isNotEmpty()) plansBox.remove(planRows) + nextPlans.forEach { plan -> + importFullPlan( + FullPlanObject( + name = plan.name ?: "Plan", + goal = plan.goal ?: "General Fitness", + description = plan.description ?: "", + days = plan.days, + ) + ) + } + } + + private fun resolveExerciseId(ex: PlanExerciseInput, mapper: FuzzyExerciseMapper? = null): String? { + ex.exerciseId?.let { if (findExerciseByUidOrNull(it) != null) return it } + ex.name?.let { name -> + val exact = exercisesBox.query(ExerciseEntity_.name.equal(name)).build().use { q -> q.findFirst()?.let { it.uid } } + if (exact != null) return exact + return mapper?.match(name) + } + return null + } + + internal fun getPlanDaysSnapshot(planId: String): List = + daysBox.query(PlanDayEntity_.planUid.equal(planId)).order(PlanDayEntity_.orderIndex).build().use { it.find() } + + internal fun getPlanExercisesSnapshot(dayId: String): List = + planExercisesBox.query(PlanExerciseEntity_.planDayUid.equal(dayId)).order(PlanExerciseEntity_.orderIndex).build().use { it.find() } + + internal fun findPlanByUidOrThrow(uid: String): PlanEntity = + plansBox.query(PlanEntity_.uid.equal(uid)).build().use { it.findFirst() } ?: error("Plan not found: $uid") + + internal fun findDayByUidOrThrow(uid: String): PlanDayEntity = + daysBox.query(PlanDayEntity_.uid.equal(uid)).build().use { it.findFirst() } ?: error("Plan day not found: $uid") + + internal fun findPlanExerciseByUidOrThrow(uid: String): PlanExerciseEntity = + planExercisesBox.query(PlanExerciseEntity_.uid.equal(uid)).build().use { it.findFirst() } ?: error("Plan exercise not found: $uid") + + private fun findExerciseByUidOrThrow(uid: String): ExerciseEntity = + findExerciseByUidOrNull(uid) ?: error("Exercise not found: $uid") + + private fun findExerciseByUidOrNull(uid: String): ExerciseEntity? = + exercisesBox.query(ExerciseEntity_.uid.equal(uid)).build().use { it.findFirst() } +} diff --git a/app/src/main/java/com/ironlog/app/data/repository/SettingsRepository.kt b/app/src/main/java/com/ironlog/app/data/repository/SettingsRepository.kt new file mode 100644 index 0000000..6f39a97 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/repository/SettingsRepository.kt @@ -0,0 +1,165 @@ +package com.ironlog.app.data.repository + +import com.ironlog.app.data.objectbox.AppSettingEntity +import com.ironlog.app.data.objectbox.AppSettingEntity_ +import com.ironlog.app.data.objectbox.ObjectBox +import io.objectbox.Box +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive + +/** Translation of settingsRepository.js. */ +class SettingsRepository( + private val settingsBox: Box = ObjectBox.store.boxFor(AppSettingEntity::class.java), + private val json: Json = Json { ignoreUnknownKeys = true }, +) { + private fun findSettingRow(key: String): AppSettingEntity? = + settingsBox.query(AppSettingEntity_.key.equal(key)).build().use { it.findFirst() } + + suspend fun getSetting(key: String): Any? = withContext(Dispatchers.IO) { + require(key.isNotBlank()) { "key must not be empty" } + val row = findSettingRow(key) ?: return@withContext null + when (row.valueType) { + "number" -> row.value.toDoubleOrNull() + "boolean" -> row.value == "true" + "json" -> runCatching { json.parseToJsonElement(row.value) }.getOrNull() + else -> row.value + } + } + + suspend fun getSettingJson(key: String): JsonElement? = getSetting(key) as? JsonElement + suspend fun getSettingString(key: String): String? = getSetting(key) as? String + suspend fun getSettingBoolean(key: String): Boolean? = getSetting(key) as? Boolean + suspend fun getSettingNumber(key: String): Double? = getSetting(key) as? Double + + suspend fun setSetting(key: String, value: Any?, valueType: String = "string") = withContext(Dispatchers.IO) { + require(key.isNotBlank()) { "key must not be empty" } + val serialized = when (valueType) { + "json" -> when (value) { + null -> "null" + is JsonElement -> value.toString() + else -> json.encodeToString(kotlinx.serialization.json.JsonElement.serializer(), JsonPrimitive(value.toString())) + } + "boolean" -> ((value as? Boolean) ?: false).toString() + else -> value?.toString() ?: "" + } + val now = System.currentTimeMillis() + val existing = findSettingRow(key) + if (existing != null) { + existing.value = serialized + existing.valueType = valueType + existing.updatedAt = now + settingsBox.put(existing) + } else { + settingsBox.put(AppSettingEntity().apply { + this.key = key + this.value = serialized + this.valueType = valueType + this.updatedAt = now + }) + } + } + + suspend fun removeSetting(key: String) = withContext(Dispatchers.IO) { + require(key.isNotBlank()) { "key must not be empty" } + findSettingRow(key)?.let { settingsBox.remove(it) } + } + + suspend fun getTheme(): String = getSettingString("theme") ?: com.ironlog.app.ui.theme.IronLogThemes.DEFAULT_THEME + suspend fun setTheme(theme: String) = setSetting("theme", theme, "string") + suspend fun getActiveWorkoutId(): String? = getSettingString("active_workout_id") + suspend fun setActiveWorkoutId(workoutId: String?) { + if (workoutId.isNullOrBlank()) removeSetting("active_workout_id") else setSetting("active_workout_id", workoutId, "string") + } + suspend fun clearActiveWorkoutId() = removeSetting("active_workout_id") + + /** + * Reads back the raw stored string for the given key. + * + * For rows with valueType="json" the historic [setSetting] implementation accidentally + * double-encoded plain strings via [JsonPrimitive], producing a JSON string-literal + * `"\"...\""` rather than the raw JSON text. This function transparently unwraps that + * old encoding so the first-restart-after-upgrade path keeps working without a migration. + */ + suspend fun getString(key: String): String? = withContext(Dispatchers.IO) { + val row = findSettingRow(key) ?: return@withContext null + val raw = row.value.takeIf { it.isNotEmpty() } ?: return@withContext null + if (row.valueType == "json") { + // Try to detect the old double-encoded format: a JSON string literal wrapping + // a JSON object/array. If parsing yields a JsonPrimitive whose content is itself + // valid JSON, return the inner content; otherwise return the raw value as-is. + runCatching { + val element = json.parseToJsonElement(raw) + if (element is JsonPrimitive && element.isString) element.content else raw + }.getOrDefault(raw) + } else raw + } + + /** + * Stores [value] verbatim — does NOT go through [setSetting]'s [JsonPrimitive] encoding + * path, which would double-encode a JSON string into `"\"...escaped...\"". + */ + suspend fun setString(key: String, value: String, valueType: String = "string") = withContext(Dispatchers.IO) { + require(key.isNotBlank()) { "key must not be empty" } + val now = System.currentTimeMillis() + val existing = findSettingRow(key) + if (existing != null) { + existing.value = value + existing.valueType = valueType + existing.updatedAt = now + settingsBox.put(existing) + } else { + settingsBox.put(AppSettingEntity().apply { + this.key = key + this.value = value + this.valueType = valueType + this.updatedAt = now + }) + } + } + + /** + * Synchronous escape hatch for lifecycle-critical writes where the caller may be disposed + * before a launched coroutine gets CPU time (for example active workout draft snapshots + * during ON_STOP). Keep normal UI writes on the suspend API. + */ + fun setStringBlocking(key: String, value: String, valueType: String = "string") { + require(key.isNotBlank()) { "key must not be empty" } + val now = System.currentTimeMillis() + val existing = findSettingRow(key) + if (existing != null) { + existing.value = value + existing.valueType = valueType + existing.updatedAt = now + settingsBox.put(existing) + } else { + settingsBox.put(AppSettingEntity().apply { + this.key = key + this.value = value + this.valueType = valueType + this.updatedAt = now + }) + } + } + suspend fun getBoolean(key: String, default: Boolean = false): Boolean = getSettingBoolean(key) ?: default + suspend fun setBoolean(key: String, value: Boolean) = setSetting(key, value, "boolean") + suspend fun isExerciseSeedComplete(): Boolean = getBoolean("exercise_seed_complete", false) + suspend fun markExerciseSeedComplete() = setBoolean("exercise_seed_complete", true) + + suspend fun loadFavorites(): Set { + val raw = getSettingJson("favorite_exercises") ?: return emptySet() + return runCatching { raw.jsonArray.map { it.jsonPrimitive.content }.filter { it.isNotBlank() }.toSet() }.getOrDefault(emptySet()) + } + + suspend fun saveFavorites(favorites: Set) { + val payload = buildJsonArray { favorites.sorted().forEach { add(JsonPrimitive(it)) } } + setSetting("favorite_exercises", payload, "json") + } +} diff --git a/app/src/main/java/com/ironlog/app/data/repository/StatsRepository.kt b/app/src/main/java/com/ironlog/app/data/repository/StatsRepository.kt new file mode 100644 index 0000000..b97afc4 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/repository/StatsRepository.kt @@ -0,0 +1,271 @@ +package com.ironlog.app.data.repository + +import com.ironlog.app.data.model.PrRecord +import com.ironlog.app.data.objectbox.ExerciseEntity +import com.ironlog.app.data.objectbox.ExerciseEntity_ +import com.ironlog.app.data.objectbox.ExerciseMuscleEntity +import com.ironlog.app.data.objectbox.ExerciseMuscleEntity_ +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.data.objectbox.WorkoutEntity +import com.ironlog.app.data.objectbox.WorkoutEntity_ +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity_ +import com.ironlog.app.data.objectbox.WorkoutSetEntity +import com.ironlog.app.data.objectbox.WorkoutSetEntity_ +import com.ironlog.app.domain.intelligence.PrLinkingEngine +import com.ironlog.app.util.calculateEstimated1RM +import com.ironlog.app.util.calculateSetVolume +import com.ironlog.app.util.startOfWeekMillis +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +class StatsRepository { + private val workoutsBox get() = ObjectBox.store.boxFor(WorkoutEntity::class.java) + private val workoutExercisesBox get() = ObjectBox.store.boxFor(WorkoutExerciseEntity::class.java) + private val workoutSetsBox get() = ObjectBox.store.boxFor(WorkoutSetEntity::class.java) + private val exercisesBox get() = ObjectBox.store.boxFor(ExerciseEntity::class.java) + private val exerciseMusclesBox get() = ObjectBox.store.boxFor(ExerciseMuscleEntity::class.java) + + fun getSessionsPerWeekFlow(): Flow { + val weekStart = startOfWeekMillis() + return workoutsBox.query(WorkoutEntity_.status.equal("completed").and(WorkoutEntity_.startedAt.greaterOrEqual(weekStart))) + .build() + .asFlow() + .map { it.size.toLong() } + .conflate() + } + + suspend fun getSessionsPerWeekSnapshot(): Long = withContext(Dispatchers.IO) { + val weekStart = startOfWeekMillis() + workoutsBox.query(WorkoutEntity_.status.equal("completed").and(WorkoutEntity_.startedAt.greaterOrEqual(weekStart))) + .build().use { it.count() } + } + + fun getWeeklyVolumeFlow(): Flow { + val weekStart = startOfWeekMillis() + return workoutsBox.query(WorkoutEntity_.status.equal("completed").and(WorkoutEntity_.startedAt.greaterOrEqual(weekStart))) + .build() + .asFlow() + .map { workouts -> + val workoutIds = workouts.map { it.uid }.toTypedArray() + if (workoutIds.isEmpty()) return@map 0.0 + val workoutExercises = workoutExercisesBox.query(WorkoutExerciseEntity_.workoutUid.oneOf(workoutIds)).build().use { it.find() } + val workoutExerciseIds = workoutExercises.map { it.uid }.toTypedArray() + if (workoutExerciseIds.isEmpty()) return@map 0.0 + val workoutSets = workoutSetsBox.query(WorkoutSetEntity_.workoutExerciseUid.oneOf(workoutExerciseIds).and(WorkoutSetEntity_.isWarmup.equal(false))).build().use { it.find() } + workoutSets.sumOf { calculateSetVolume(it.weight, it.reps) } + } + .conflate() + } + + suspend fun getWeeklyVolumeSnapshot(): Double = withContext(Dispatchers.IO) { + val weekStart = startOfWeekMillis() + val workouts = workoutsBox.query(WorkoutEntity_.status.equal("completed").and(WorkoutEntity_.startedAt.greaterOrEqual(weekStart))).build().use { it.find() } + val workoutIds = workouts.map { it.uid }.toTypedArray() + if (workoutIds.isEmpty()) return@withContext 0.0 + val workoutExercises = workoutExercisesBox.query(WorkoutExerciseEntity_.workoutUid.oneOf(workoutIds)).build().use { it.find() } + val workoutExerciseIds = workoutExercises.map { it.uid }.toTypedArray() + if (workoutExerciseIds.isEmpty()) return@withContext 0.0 + val workoutSets = workoutSetsBox.query(WorkoutSetEntity_.workoutExerciseUid.oneOf(workoutExerciseIds).and(WorkoutSetEntity_.isWarmup.equal(false))).build().use { it.find() } + workoutSets.sumOf { calculateSetVolume(it.weight, it.reps) } + } + + fun getExerciseEstimatedOneRepMaxFlow(exerciseId: String): Flow { + val completedWorkouts = workoutsBox.query(WorkoutEntity_.status.equal("completed")).build().asFlow() + val exerciseRows = workoutExercisesBox.query(WorkoutExerciseEntity_.exerciseUid.equal(exerciseId)).build().asFlow() + val workingSets = workoutSetsBox.query(WorkoutSetEntity_.isWarmup.equal(false)).build().asFlow() + return combine(completedWorkouts, exerciseRows, workingSets) { workouts, rows, sets -> + val completedIds = workouts.mapTo(hashSetOf()) { it.uid } + val eligibleRowIds = rows.asSequence() + .filter { it.workoutUid in completedIds } + .mapTo(hashSetOf()) { it.uid } + sets.asSequence() + .filter { it.workoutExerciseUid in eligibleRowIds } + .maxOfOrNull { calculateEstimated1RM(it.weight, it.reps) } + ?: 0.0 + } + .conflate() + } + + suspend fun getExerciseEstimatedOneRepMaxSnapshot(exerciseId: String): Double = withContext(Dispatchers.IO) { + val completedIds = workoutsBox.query(WorkoutEntity_.status.equal("completed")).build().use { it.find() } + .mapTo(hashSetOf()) { it.uid } + if (completedIds.isEmpty()) return@withContext 0.0 + val exerciseRows = workoutExercisesBox.query(WorkoutExerciseEntity_.exerciseUid.equal(exerciseId)).build().use { it.find() } + .filter { it.workoutUid in completedIds } + val exerciseRowIds = exerciseRows.map { it.uid }.toTypedArray() + if (exerciseRowIds.isEmpty()) return@withContext 0.0 + val sets = workoutSetsBox.query(WorkoutSetEntity_.workoutExerciseUid.oneOf(exerciseRowIds).and(WorkoutSetEntity_.isWarmup.equal(false))).build().use { it.find() } + var max = 0.0 + sets.forEach { set -> + max = kotlin.math.max(max, calculateEstimated1RM(set.weight, set.reps)) + } + max + } + + fun getPRsFlow(): Flow> { + return workoutsBox.query(WorkoutEntity_.status.equal("completed")).orderDesc(WorkoutEntity_.startedAt) + .build() + .asFlow() + .map { workouts -> + val workoutIds = workouts.map { it.uid }.toTypedArray() + if (workoutIds.isEmpty()) return@map emptyList() + val workoutExercises = workoutExercisesBox.query(WorkoutExerciseEntity_.workoutUid.oneOf(workoutIds)).build().use { it.find() } + val workoutExerciseIds = workoutExercises.map { it.uid }.toTypedArray() + if (workoutExerciseIds.isEmpty()) return@map emptyList() + val sets = workoutSetsBox.query(WorkoutSetEntity_.workoutExerciseUid.oneOf(workoutExerciseIds).and(WorkoutSetEntity_.isWarmup.equal(false))).build().use { it.find() } + val exerciseIds = workoutExercises.map { it.exerciseUid }.distinct().toTypedArray() + val exercises = if (exerciseIds.isEmpty()) { + emptyList() + } else { + exercisesBox.query(ExerciseEntity_.uid.oneOf(exerciseIds)).build().use { it.find() } + } + val exByWorkoutExerciseId = workoutExercises.associate { it.uid to it.exerciseUid } + val exerciseById = exercises.associateBy { it.uid } + val best = linkedMapOf() + + sets.forEach { set -> + val exerciseId = exByWorkoutExerciseId[set.workoutExerciseUid] ?: return@forEach + val ex = exerciseById[exerciseId] + val groupId = PrLinkingEngine.getPrGroupId( + name = ex?.name.orEmpty(), + primaryMuscle = ex?.primaryMuscle, + equipment = ex?.equipment, + category = ex?.category, + mode = "safe", + ) ?: "exact_${ex?.name ?: exerciseId}" + val candidate = calculateEstimated1RM(set.weight, set.reps) + val current = best[groupId] + if (current == null || candidate > current.estimated1RM) { + best[groupId] = PrRecord( + exerciseId = exerciseId, + exerciseName = ex?.name ?: "Unknown", + exercisePrGroupId = groupId, + weight = set.weight, + reps = set.reps, + estimated1RM = candidate, + ) + } + } + best.values.sortedByDescending { it.estimated1RM } + } + .conflate() + } + + suspend fun getPRsSnapshot(): List = withContext(Dispatchers.IO) { + val workouts = workoutsBox.query(WorkoutEntity_.status.equal("completed")).build().use { it.find() } + val workoutIds = workouts.map { it.uid }.toTypedArray() + if (workoutIds.isEmpty()) return@withContext emptyList() + val workoutExercises = workoutExercisesBox.query(WorkoutExerciseEntity_.workoutUid.oneOf(workoutIds)).build().use { it.find() } + val workoutExerciseIds = workoutExercises.map { it.uid }.toTypedArray() + if (workoutExerciseIds.isEmpty()) return@withContext emptyList() + val sets = workoutSetsBox.query(WorkoutSetEntity_.workoutExerciseUid.oneOf(workoutExerciseIds).and(WorkoutSetEntity_.isWarmup.equal(false))).build().use { it.find() } + val exerciseIds = workoutExercises.map { it.exerciseUid }.distinct().toTypedArray() + val exercises = if (exerciseIds.isEmpty()) { + emptyList() + } else { + exercisesBox.query(ExerciseEntity_.uid.oneOf(exerciseIds)).build().use { it.find() } + } + val exByWorkoutExerciseId = workoutExercises.associate { it.uid to it.exerciseUid } + val exerciseById = exercises.associateBy { it.uid } + val best = linkedMapOf() + + sets.forEach { set -> + val exerciseId = exByWorkoutExerciseId[set.workoutExerciseUid] ?: return@forEach + val ex = exerciseById[exerciseId] + val groupId = PrLinkingEngine.getPrGroupId( + name = ex?.name.orEmpty(), + primaryMuscle = ex?.primaryMuscle, + equipment = ex?.equipment, + category = ex?.category, + mode = "safe", + ) ?: "exact_${ex?.name ?: exerciseId}" + val candidate = calculateEstimated1RM(set.weight, set.reps) + val current = best[groupId] + if (current == null || candidate > current.estimated1RM) { + best[groupId] = PrRecord( + exerciseId = exerciseId, + exerciseName = ex?.name ?: "Unknown", + exercisePrGroupId = groupId, + weight = set.weight, + reps = set.reps, + estimated1RM = candidate, + ) + } + } + best.values.sortedByDescending { it.estimated1RM } + } + + fun getMuscleVolumeFlow(timeRange: Int = 14): Flow> { + val from = System.currentTimeMillis() - timeRange * 24L * 60L * 60L * 1000L + return workoutsBox.query(WorkoutEntity_.status.equal("completed").and(WorkoutEntity_.startedAt.greaterOrEqual(from))) + .build() + .asFlow() + .map { workouts -> + val workoutIds = workouts.map { it.uid }.toTypedArray() + if (workoutIds.isEmpty()) return@map emptyMap() + val workoutExerciseRows = workoutExercisesBox.query(WorkoutExerciseEntity_.workoutUid.oneOf(workoutIds)).build().use { it.find() } + val workoutExerciseById = workoutExerciseRows.associateBy { it.uid } + val workoutExerciseIds = workoutExerciseRows.map { it.uid }.toTypedArray() + if (workoutExerciseIds.isEmpty()) return@map emptyMap() + val sets = workoutSetsBox.query(WorkoutSetEntity_.workoutExerciseUid.oneOf(workoutExerciseIds).and(WorkoutSetEntity_.isWarmup.equal(false))).build().use { it.find() } + val exerciseIds = workoutExerciseRows.map { it.exerciseUid }.distinct().toTypedArray() + val musclesByExercise = if (exerciseIds.isEmpty()) { + emptyMap() + } else { + exerciseMusclesBox.query(ExerciseMuscleEntity_.exerciseUid.oneOf(exerciseIds)) + .build().use { it.find() } + .groupBy { it.exerciseUid } + } + val totals = linkedMapOf() + + sets.forEach { set -> + val workoutExercise = workoutExerciseById[set.workoutExerciseUid] ?: return@forEach + val volume = calculateSetVolume(set.weight, set.reps) + val muscles = musclesByExercise[workoutExercise.exerciseUid].orEmpty() + muscles.forEach { muscle -> + val contrib = volume * muscle.contributionFraction + totals[muscle.muscle] = (totals[muscle.muscle] ?: 0.0) + contrib + } + } + totals + } + .conflate() + } + + suspend fun getMuscleVolumeSnapshot(timeRange: Int = 14): Map = withContext(Dispatchers.IO) { + val from = System.currentTimeMillis() - timeRange * 24L * 60L * 60L * 1000L + val workouts = workoutsBox.query(WorkoutEntity_.status.equal("completed").and(WorkoutEntity_.startedAt.greaterOrEqual(from))).build().use { it.find() } + val workoutIds = workouts.map { it.uid }.toTypedArray() + if (workoutIds.isEmpty()) return@withContext emptyMap() + val workoutExerciseRows = workoutExercisesBox.query(WorkoutExerciseEntity_.workoutUid.oneOf(workoutIds)).build().use { it.find() } + val workoutExerciseById = workoutExerciseRows.associateBy { it.uid } + val workoutExerciseIds = workoutExerciseRows.map { it.uid }.toTypedArray() + if (workoutExerciseIds.isEmpty()) return@withContext emptyMap() + val sets = workoutSetsBox.query(WorkoutSetEntity_.workoutExerciseUid.oneOf(workoutExerciseIds).and(WorkoutSetEntity_.isWarmup.equal(false))).build().use { it.find() } + val exerciseIds = workoutExerciseRows.map { it.exerciseUid }.distinct().toTypedArray() + val musclesByExercise = if (exerciseIds.isEmpty()) { + emptyMap() + } else { + exerciseMusclesBox.query(ExerciseMuscleEntity_.exerciseUid.oneOf(exerciseIds)) + .build().use { it.find() } + .groupBy { it.exerciseUid } + } + val totals = linkedMapOf() + + sets.forEach { set -> + val workoutExercise = workoutExerciseById[set.workoutExerciseUid] ?: return@forEach + val volume = calculateSetVolume(set.weight, set.reps) + val muscles = musclesByExercise[workoutExercise.exerciseUid].orEmpty() + muscles.forEach { muscle -> + val contrib = volume * muscle.contributionFraction + totals[muscle.muscle] = (totals[muscle.muscle] ?: 0.0) + contrib + } + } + totals + } +} diff --git a/app/src/main/java/com/ironlog/app/data/repository/WorkoutRepository.kt b/app/src/main/java/com/ironlog/app/data/repository/WorkoutRepository.kt new file mode 100644 index 0000000..96b47fd --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/repository/WorkoutRepository.kt @@ -0,0 +1,558 @@ +package com.ironlog.app.data.repository + +import com.ironlog.app.data.model.CompletedExerciseInput +import com.ironlog.app.data.model.CreateCompletedWorkoutInput +import com.ironlog.app.data.model.SetInput +import com.ironlog.app.data.model.WorkoutDetail +import com.ironlog.app.data.model.WorkoutMetadataInput +import com.ironlog.app.data.objectbox.AppSettingEntity +import com.ironlog.app.data.objectbox.AppSettingEntity_ +import com.ironlog.app.data.objectbox.ExerciseEntity +import com.ironlog.app.data.objectbox.ExerciseEntity_ +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.data.objectbox.PlanDayEntity_ +import com.ironlog.app.data.objectbox.PlanExerciseEntity_ +import com.ironlog.app.data.objectbox.WorkoutEntity +import com.ironlog.app.data.objectbox.WorkoutEntity_ +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity_ +import com.ironlog.app.data.objectbox.WorkoutSetEntity +import com.ironlog.app.data.objectbox.WorkoutSetEntity_ +import com.ironlog.app.util.calculateSetVolume +import com.ironlog.app.util.normalizeExerciseNameKey +import com.ironlog.app.util.requireNonEmpty +import com.ironlog.app.util.requireNumberMin +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import kotlin.math.max +import kotlin.math.roundToInt + +data class PostWorkoutMetricsResult( + val newlyRecorded: Boolean, + val cumulativeVolumeKg: Double, +) + +class WorkoutRepository( + private val settingsRepository: SettingsRepository = SettingsRepository(), +) { + private val workoutsBox get() = ObjectBox.store.boxFor(WorkoutEntity::class.java) + private val workoutExercisesBox get() = ObjectBox.store.boxFor(WorkoutExerciseEntity::class.java) + private val workoutSetsBox get() = ObjectBox.store.boxFor(WorkoutSetEntity::class.java) + private val planDaysBox get() = ObjectBox.store.boxFor(com.ironlog.app.data.objectbox.PlanDayEntity::class.java) + private val planExercisesBox get() = ObjectBox.store.boxFor(com.ironlog.app.data.objectbox.PlanExerciseEntity::class.java) + private val exercisesBox get() = ObjectBox.store.boxFor(ExerciseEntity::class.java) + private val settingsBox get() = ObjectBox.store.boxFor(AppSettingEntity::class.java) + + suspend fun startWorkoutFromPlanDay(planDayId: String): WorkoutEntity = withContext(Dispatchers.IO) { + requireNonEmpty(planDayId, "planDayId") + val now = System.currentTimeMillis() + val startedAt = readIntendedWorkoutStartMs(now) + val planDay = planDaysBox.query(PlanDayEntity_.uid.equal(planDayId)).build().use { it.findFirst() } + ?: error("Plan day not found: $planDayId") + val planRows = planExercisesBox.query(PlanExerciseEntity_.planDayUid.equal(planDayId)) + .order(PlanExerciseEntity_.orderIndex).build().use { it.find() } + val workout = WorkoutEntity().apply { + this.planDay.target = planDay + planDayUid = planDay.uid + plan.target = planDay.plan.target + planUid = planDay.planUid.ifBlank { null } + name = "${planDay.name.ifBlank { "Workout" }} Session" + this.startedAt = startedAt + durationSeconds = 0 + status = "active" + createdAt = now + updatedAt = now + } + val workoutExerciseRows = planRows.mapIndexedNotNull { index, planExercise -> + val exercise = exercisesBox.query(ExerciseEntity_.uid.equal(planExercise.exerciseUid)).build().use { it.findFirst() } ?: return@mapIndexedNotNull null + WorkoutExerciseEntity().apply { + this.workout.target = workout + workoutUid = workout.uid + this.exercise.target = exercise + exerciseUid = exercise.uid + orderIndex = index + supersetGroup = planExercise.supersetGroup.ifBlank { "" } + notes = planExercise.notes.ifBlank { "" } + createdAt = now + updatedAt = now + } + } + ObjectBox.store.runInTx { + workoutsBox.put(workout) + if (workoutExerciseRows.isNotEmpty()) workoutExercisesBox.put(workoutExerciseRows) + writeActiveWorkoutSettings(workout.uid, planDayId, planDay.name.ifBlank { "Workout" }, now) + } + // active_workout_start_ms is NOT written here — the timer only begins when the + // first set is logged (see ActiveWorkoutViewModel.startTimerOnFirstSet). + workout + } + + suspend fun startEmptyWorkout(name: String): WorkoutEntity = withContext(Dispatchers.IO) { + requireNonEmpty(name, "name") + val now = System.currentTimeMillis() + val startedAt = readIntendedWorkoutStartMs(now) + val workout = WorkoutEntity().apply { + this.name = name.trim() + this.startedAt = startedAt + durationSeconds = 0 + status = "active" + createdAt = now + updatedAt = now + } + ObjectBox.store.runInTx { + workoutsBox.put(workout) + writeActiveWorkoutSettings(workout.uid, null, name.trim(), now) + } + // active_workout_start_ms is NOT written here — timer starts on first set. + workout + } + + private suspend fun readIntendedWorkoutStartMs(now: Long): Long { + val dateKey = settingsRepository.getString("active_workout_intended_date") + if (dateKey.isNullOrBlank()) return now + return runCatching { + val selectedDate = LocalDate.parse(dateKey) + val today = LocalDate.now() + if (selectedDate.isAfter(today)) return now + selectedDate + .atTime(LocalTime.now()) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + }.getOrDefault(now) + } + + suspend fun addExerciseToWorkout(workoutId: String, exerciseId: String): WorkoutExerciseEntity = withContext(Dispatchers.IO) { + requireNonEmpty(workoutId, "workoutId") + requireNonEmpty(exerciseId, "exerciseId") + val workout = findWorkoutByUidOrThrow(workoutId) + val exercise = findExerciseByUidOrThrow(exerciseId) + val now = System.currentTimeMillis() + val count = workoutExercisesBox.query(WorkoutExerciseEntity_.workoutUid.equal(workoutId)).build().use { it.count().toInt() } + val created = WorkoutExerciseEntity().apply { + this.workout.target = workout + workoutUid = workout.uid + this.exercise.target = exercise + exerciseUid = exercise.uid + orderIndex = count + supersetGroup = "" + notes = "" + createdAt = now + updatedAt = now + } + workoutExercisesBox.put(created) + created + } + + suspend fun swapWorkoutExercise(workoutExerciseId: String, newExerciseId: String): WorkoutExerciseEntity = withContext(Dispatchers.IO) { + requireNonEmpty(workoutExerciseId, "workoutExerciseId") + requireNonEmpty(newExerciseId, "newExerciseId") + val row = findWorkoutExerciseByUidOrThrow(workoutExerciseId) + val exercise = findExerciseByUidOrThrow(newExerciseId) + row.exercise.target = exercise + row.exerciseUid = exercise.uid + row.updatedAt = System.currentTimeMillis() + workoutExercisesBox.put(row) + row + } + + suspend fun updateWorkoutExerciseSuperset(workoutExerciseId: String, group: String?): WorkoutExerciseEntity = withContext(Dispatchers.IO) { + requireNonEmpty(workoutExerciseId, "workoutExerciseId") + val row = findWorkoutExerciseByUidOrThrow(workoutExerciseId) + row.supersetGroup = group?.trim().orEmpty() + row.updatedAt = System.currentTimeMillis() + workoutExercisesBox.put(row) + row + } + + suspend fun removeExerciseFromWorkout(workoutExerciseId: String) = withContext(Dispatchers.IO) { + requireNonEmpty(workoutExerciseId, "workoutExerciseId") + val workoutExercise = findWorkoutExerciseByUidOrThrow(workoutExerciseId) + val sets = getSetsForWorkoutExercise(workoutExerciseId) + workoutSetsBox.remove(sets) + workoutExercisesBox.remove(workoutExercise) + Unit + } + + suspend fun addSet(workoutExerciseId: String, input: SetInput): WorkoutSetEntity = withContext(Dispatchers.IO) { + requireNonEmpty(workoutExerciseId, "workoutExerciseId") + requireNumberMin(input.weight ?: 0.0, 0.0, "weight") + requireNumberMin(input.reps ?: 0.0, 0.0, "reps") + requireNumberMin(input.restSeconds ?: 0, 0.0, "restSeconds") + val workoutExercise = findWorkoutExerciseByUidOrThrow(workoutExerciseId) + val now = System.currentTimeMillis() + val count = workoutSetsBox.query(WorkoutSetEntity_.workoutExerciseUid.equal(workoutExerciseId)).build().use { it.count().toInt() } + val created = WorkoutSetEntity().apply { + this.workoutExercise.target = workoutExercise + this.workoutExerciseUid = workoutExercise.uid + setIndex = count + 1 + weight = input.weight ?: 0.0 + reps = input.reps ?: 0.0 + rpe = input.rpe + rir = input.rir + restSeconds = input.restSeconds ?: 0 + isWarmup = input.isWarmup == true + isDropset = input.isDropset == true + isAmrap = input.isAmrap == true + toFailure = input.toFailure == true + completedAt = input.completedAt ?: now + createdAt = now + updatedAt = now + } + workoutSetsBox.put(created) + created + } + + suspend fun updateSet(setId: String, input: SetInput): WorkoutSetEntity = withContext(Dispatchers.IO) { + requireNonEmpty(setId, "setId") + val row = findSetByUidOrThrow(setId) + if (input.setIndex != null) row.setIndex = input.setIndex + if (input.weight != null) row.weight = input.weight + if (input.reps != null) row.reps = input.reps + if (input.rpe != null) row.rpe = input.rpe + if (input.rir != null) row.rir = input.rir + if (input.restSeconds != null) row.restSeconds = input.restSeconds + if (input.isWarmup != null) row.isWarmup = input.isWarmup + if (input.isDropset != null) row.isDropset = input.isDropset + if (input.isAmrap != null) row.isAmrap = input.isAmrap + if (input.toFailure != null) row.toFailure = input.toFailure + if (input.completedAt != null) row.completedAt = input.completedAt.takeIf { it != 0L } + row.updatedAt = System.currentTimeMillis() + workoutSetsBox.put(row) + row + } + + suspend fun updateSetByWorkoutExerciseAndOrder( + workoutExerciseId: String, + setOrderIndex: Int, + input: SetInput, + ): WorkoutSetEntity = withContext(Dispatchers.IO) { + requireNonEmpty(workoutExerciseId, "workoutExerciseId") + require(setOrderIndex > 0) { "setOrderIndex must be >= 1" } + val row = workoutSetsBox.query( + WorkoutSetEntity_.workoutExerciseUid.equal(workoutExerciseId) + .and(WorkoutSetEntity_.setIndex.equal(setOrderIndex.toLong())), + ).build().use { it.findFirst() } ?: error("Set not found for workoutExerciseId=$workoutExerciseId setIndex=$setOrderIndex") + updateSet(row.uid, input) + } + + suspend fun deleteSet(setId: String) = withContext(Dispatchers.IO) { + requireNonEmpty(setId, "setId") + workoutSetsBox.remove(findSetByUidOrThrow(setId)) + Unit + } + + suspend fun deleteWorkoutExercise(workoutExerciseId: String) = withContext(Dispatchers.IO) { + requireNonEmpty(workoutExerciseId, "workoutExerciseId") + val entity = runCatching { findWorkoutExerciseByUidOrThrow(workoutExerciseId) }.getOrNull() ?: return@withContext + // Also delete all sets belonging to this exercise + val sets = workoutSetsBox.query( + com.ironlog.app.data.objectbox.WorkoutSetEntity_.workoutExerciseUid.equal(workoutExerciseId) + ).build().use { it.find() } + workoutSetsBox.remove(sets) + workoutExercisesBox.remove(entity) + } + + suspend fun completeWorkout( + workoutId: String, + durationStartEpochMs: Long? = null, + metadata: WorkoutMetadataInput = WorkoutMetadataInput(), + ): WorkoutEntity = withContext(Dispatchers.IO) { + requireNonEmpty(workoutId, "workoutId") + val workout = findWorkoutByUidOrThrow(workoutId) + val now = System.currentTimeMillis() + ObjectBox.store.runInTx { + if (workout.status != "completed") { + val validDurationStart = durationStartEpochMs?.takeIf { it in 1..now } + val rawDurationSec = validDurationStart?.let { ((now - it) / 1000.0).roundToInt() } + ?: workout.durationSeconds.coerceAtLeast(0) + workout.durationSeconds = rawDurationSec.coerceIn(0, 86_400) + workout.status = "completed" + workout.completedAt = if (workout.startedAt > 0L && now - workout.startedAt >= 86_400_000L) { + workout.startedAt + workout.durationSeconds * 1000L + } else { + now + } + if (metadata.rating != null) workout.rating = metadata.rating + if (metadata.notes != null) workout.notes = metadata.notes + workout.updatedAt = now + workoutsBox.put(workout) + } + clearActiveWorkoutSettings(workoutId) + } + workout + } + + suspend fun abandonWorkout(workoutId: String): WorkoutEntity = withContext(Dispatchers.IO) { + requireNonEmpty(workoutId, "workoutId") + val workout = findWorkoutByUidOrThrow(workoutId) + val now = System.currentTimeMillis() + ObjectBox.store.runInTx { + if (workout.status != "abandoned") { + workout.status = "abandoned" + workout.completedAt = now + workout.updatedAt = now + workoutsBox.put(workout) + } + clearActiveWorkoutSettings(workoutId) + } + workout + } + + suspend fun recordPostWorkoutMetrics( + workoutId: String, + volumeKg: Double, + hasNewPr: Boolean, + ): PostWorkoutMetricsResult = withContext(Dispatchers.IO) { + requireNonEmpty(workoutId, "workoutId") + val markerKey = "workout_post_processed_$workoutId" + var result = PostWorkoutMetricsResult(newlyRecorded = false, cumulativeVolumeKg = 0.0) + ObjectBox.store.runInTx { + val existingMarker = settingsBox.query(AppSettingEntity_.key.equal(markerKey)) + .build().use { it.findFirst() } + val lifetimeRow = settingsBox.query(AppSettingEntity_.key.equal("lifetime_volume_kg")) + .build().use { it.findFirst() } + val currentLifetime = lifetimeRow?.value?.toDoubleOrNull() ?: 0.0 + if (existingMarker != null) { + result = PostWorkoutMetricsResult(false, currentLifetime) + return@runInTx + } + + val now = System.currentTimeMillis() + val cumulative = currentLifetime + volumeKg.coerceAtLeast(0.0) + putSetting("lifetime_volume_kg", cumulative.toString(), now) + if (hasNewPr) putSetting("widget_last_new_pb_ms", now.toString(), now) + putSetting(markerKey, "true", now) + result = PostWorkoutMetricsResult(true, cumulative) + } + result + } + + private fun writeActiveWorkoutSettings( + workoutId: String, + dayId: String?, + dayName: String, + now: Long, + ) { + putSetting("active_workout_id", workoutId, now) + if (dayId.isNullOrBlank()) removeSettings("active_workout_day_id") + else putSetting("active_workout_day_id", dayId, now) + putSetting("active_workout_day_name", dayName, now) + removeSettings( + "active_workout_start_ms", + "active_workout_set_label", + "active_workout_rest_end_ms", + "active_workout_intended_date", + ) + } + + private fun clearActiveWorkoutSettings(workoutId: String) { + removeSettings( + "active_workout_id", + "active_workout_day_id", + "active_workout_day_name", + "active_workout_start_ms", + "active_workout_set_label", + "active_workout_rest_end_ms", + "active_workout_draft_$workoutId", + "active_workout_rest_override_$workoutId", + ) + } + + private fun putSetting(key: String, value: String, now: Long) { + settingsBox.put(AppSettingEntity().apply { + this.key = key + this.value = value + valueType = "string" + updatedAt = now + }) + } + + private fun removeSettings(vararg keys: String) { + if (keys.isEmpty()) return + val rows = settingsBox.query(AppSettingEntity_.key.oneOf(keys)).build().use { it.find() } + settingsBox.remove(rows) + } + + fun getCompletedWorkoutsFlow(): Flow> = + workoutsBox.query(WorkoutEntity_.status.equal("completed")).orderDesc(WorkoutEntity_.startedAt).build().asFlow() + + suspend fun updateWorkoutMetadata(workoutId: String, input: WorkoutMetadataInput): WorkoutEntity = withContext(Dispatchers.IO) { + requireNonEmpty(workoutId, "workoutId") + val workout = findWorkoutByUidOrThrow(workoutId) + if (input.startedAt != null) { + workout.startedAt = input.startedAt + if ((workout.completedAt ?: 0L) < workout.startedAt) workout.completedAt = workout.startedAt + } + if (input.durationSeconds != null) workout.durationSeconds = max(0, input.durationSeconds) + if (input.rating != null) workout.rating = input.rating + if (input.notes != null) workout.notes = input.notes + workout.updatedAt = System.currentTimeMillis() + workoutsBox.put(workout) + workout + } + + suspend fun createCompletedWorkout(input: CreateCompletedWorkoutInput): WorkoutEntity = withContext(Dispatchers.IO) { + val now = System.currentTimeMillis() + val completedAt = input.startedAt + input.durationSeconds * 1000L + + // Extract unique normalized names of the incoming exercises to avoid loading the entire table. + val targetNames = input.exerciseData.mapNotNull { it.name?.let(::normalizeExerciseNameKey) }.distinct().toTypedArray() + val existing = if (targetNames.isEmpty()) emptyList() else { + exercisesBox.query(ExerciseEntity_.normalizedName.oneOf(targetNames)).build().use { it.find() } + } + val exByNormName = existing.associateBy { it.normalizedName }.toMutableMap() + + var workoutResult: WorkoutEntity? = null + ObjectBox.store.runInTx { + val missing = input.exerciseData.filter { ex -> !exByNormName.containsKey(normalizeExerciseNameKey(ex.name ?: "")) } + for (ex in missing) { + val normName = normalizeExerciseNameKey(ex.name ?: "Exercise") + val created = ExerciseEntity().apply { + name = ex.name ?: "Exercise" + normalizedName = normName + primaryMuscle = ex.primaryMuscles?.firstOrNull() ?: ex.primaryMuscle ?: "Other" + equipment = ex.equipment ?: "Other" + category = ex.category ?: "strength" + isCustom = true + source = "user_custom" + notes = "" + createdAt = now + updatedAt = now + isBodyweight = equipment.lowercase() == "bodyweight" + } + exercisesBox.put(created) + exByNormName[normName] = created + } + + val workout = WorkoutEntity().apply { + name = input.name ?: "Workout" + startedAt = input.startedAt + this.completedAt = completedAt + durationSeconds = max(0.0, input.durationSeconds.toDouble()).roundToInt() + rating = input.rating + notes = input.notes ?: "" + status = "completed" + createdAt = input.startedAt + updatedAt = now + } + workoutsBox.put(workout) + + input.exerciseData.forEachIndexed { exIndex, ex -> + val normName = normalizeExerciseNameKey(ex.name ?: "") + val exercise = ex.exerciseId?.let(::findExerciseByUidOrNull) ?: exByNormName[normName] ?: return@forEachIndexed + val we = WorkoutExerciseEntity().apply { + this.workout.target = workout + workoutUid = workout.uid + this.exercise.target = exercise + exerciseUid = exercise.uid + orderIndex = exIndex + supersetGroup = ex.supersetGroup ?: "" + notes = ex.note ?: ex.notes ?: "" + createdAt = input.startedAt + updatedAt = now + } + workoutExercisesBox.put(we) + val setRows = ex.sets.mapIndexed { si, s -> + WorkoutSetEntity().apply { + workoutExercise.target = we + workoutExerciseUid = we.uid + setIndex = si + 1 + weight = max(0.0, s.weight ?: 0.0) + reps = max(0.0, s.reps ?: 0.0) + rpe = s.rpe + rir = s.rir + restSeconds = max(0, s.restSeconds ?: s.rest ?: 0) + isWarmup = s.type == "warmup" || s.isWarmup == true + isDropset = s.type == "dropset" || s.isDropset == true + isAmrap = s.type == "amrap" || s.isAmrap == true || s.isAMRAP == true + toFailure = s.type == "failure" || s.toFailure == true + this.completedAt = completedAt + createdAt = input.startedAt + updatedAt = now + } + } + if (setRows.isNotEmpty()) workoutSetsBox.put(setRows) + } + workoutResult = workout + } + workoutResult ?: error("Failed to save workout in transaction") + } + + suspend fun clearCompletedWorkouts() = withContext(Dispatchers.IO) { + val workouts = workoutsBox.query(WorkoutEntity_.status.equal("completed")).build().use { it.find() } + if (workouts.isEmpty()) return@withContext + val workoutIds = workouts.map { it.uid }.toTypedArray() + val wes = workoutExercisesBox.query(WorkoutExerciseEntity_.workoutUid.oneOf(workoutIds)).build().use { it.find() } + if (wes.isNotEmpty()) { + val weIds = wes.map { it.uid }.toTypedArray() + val sets = workoutSetsBox.query(WorkoutSetEntity_.workoutExerciseUid.oneOf(weIds)).build().use { it.find() } + if (sets.isNotEmpty()) { + workoutSetsBox.remove(sets) + } + workoutExercisesBox.remove(wes) + } + workoutsBox.remove(workouts) + } + + suspend fun getWorkoutDetailSnapshot(workoutId: String): WorkoutDetail = withContext(Dispatchers.IO) { + requireNonEmpty(workoutId, "workoutId") + val workout = findWorkoutByUidOrThrow(workoutId) + val exercises = workoutExercisesBox.query(WorkoutExerciseEntity_.workoutUid.equal(workoutId)).order(WorkoutExerciseEntity_.orderIndex).build().use { it.find() } + val exerciseIds = exercises.map { it.uid }.toSet() + val scopedSets = if (exerciseIds.isEmpty()) emptyList() else { + workoutSetsBox.query(WorkoutSetEntity_.workoutExerciseUid.oneOf(exerciseIds.toTypedArray())) + .order(WorkoutSetEntity_.setIndex) + .build().use { it.find() } + } + val totalVolume = scopedSets.sumOf { calculateSetVolume(it.weight, it.reps) } + WorkoutDetail(workout, exercises, scopedSets, totalVolume) + } + + /** Returns non-warmup sets from the most recent completed workout that contained [exerciseId]. */ + suspend fun getLastSessionSetsForExercise(exerciseId: String): List = withContext(Dispatchers.IO) { + val recentWes = workoutExercisesBox + .query(WorkoutExerciseEntity_.exerciseUid.equal(exerciseId)) + .build().use { it.find() } + .sortedByDescending { it.createdAt } + .take(10) + + for (we in recentWes) { + workoutsBox.query( + WorkoutEntity_.uid.equal(we.workoutUid) + .and(WorkoutEntity_.status.equal("completed")), + ).build().use { it.findFirst() } ?: continue + + val sets = workoutSetsBox + .query(WorkoutSetEntity_.workoutExerciseUid.equal(we.uid)) + .order(WorkoutSetEntity_.setIndex) + .build().use { it.find() } + .filter { !it.isWarmup } + + if (sets.isNotEmpty()) return@withContext sets + } + emptyList() + } + + private fun getSetsForWorkoutExercise(workoutExerciseId: String): List = + workoutSetsBox.query(WorkoutSetEntity_.workoutExerciseUid.equal(workoutExerciseId)).build().use { it.find() } + + private fun findWorkoutByUidOrThrow(uid: String): WorkoutEntity = + workoutsBox.query(WorkoutEntity_.uid.equal(uid)).build().use { it.findFirst() } ?: error("Workout not found: $uid") + + private fun findWorkoutExerciseByUidOrThrow(uid: String): WorkoutExerciseEntity = + workoutExercisesBox.query(WorkoutExerciseEntity_.uid.equal(uid)).build().use { it.findFirst() } ?: error("Workout exercise not found: $uid") + + private fun findSetByUidOrThrow(uid: String): WorkoutSetEntity = + workoutSetsBox.query(WorkoutSetEntity_.uid.equal(uid)).build().use { it.findFirst() } ?: error("Set not found: $uid") + + private fun findExerciseByUidOrThrow(uid: String): ExerciseEntity = + findExerciseByUidOrNull(uid) ?: error("Exercise not found: $uid") + + private fun findExerciseByUidOrNull(uid: String): ExerciseEntity? = + exercisesBox.query(ExerciseEntity_.uid.equal(uid)).build().use { it.findFirst() } +} diff --git a/app/src/main/java/com/ironlog/app/data/seed/ExerciseSeed.kt b/app/src/main/java/com/ironlog/app/data/seed/ExerciseSeed.kt new file mode 100644 index 0000000..d9c22f0 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/seed/ExerciseSeed.kt @@ -0,0 +1,208 @@ +package com.ironlog.app.data.seed + +import android.content.Context +import com.ironlog.app.util.ExerciseTrackingTypeNormalizer +import com.ironlog.app.data.model.ExerciseSeedEntry +import com.ironlog.app.data.model.ExerciseSeedPayload +import com.ironlog.app.data.objectbox.ExerciseEntity +import com.ironlog.app.data.objectbox.ExerciseEntity_ +import com.ironlog.app.data.objectbox.ExerciseMuscleEntity +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.util.normalizeExerciseNameKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.util.Locale +import kotlin.math.max + +class ExerciseSeed( + private val context: Context, + private val settingsRepository: SettingsRepository = SettingsRepository(), +) { + private val exerciseBox get() = ObjectBox.store.boxFor(ExerciseEntity::class.java) + private val muscleBox get() = ObjectBox.store.boxFor(ExerciseMuscleEntity::class.java) + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + data class SeedResult(val seeded: Boolean, val reason: String? = null, val count: Int? = null) + data class BackfillResult(val backfilled: Boolean, val reason: String? = null, val count: Int? = null) + + suspend fun seedExercisesIfNeeded(): SeedResult = withContext(Dispatchers.IO) { + if (settingsRepository.isExerciseSeedComplete()) { + val repaired = repairExerciseTrackingTypes() + return@withContext SeedResult( + seeded = false, + reason = if (repaired > 0) "already_seeded_repaired_tracking_types" else "already_seeded", + count = repaired.takeIf { it > 0 }, + ) + } + + val rows = loadPayload().exercises.filter { !it.name.isNullOrBlank() } + val existing = exerciseBox.all + val existingKeys = existing.map { it.normalizedName }.toMutableSet() + val insertedByKey = linkedMapOf>() + val now = System.currentTimeMillis() + + val exercisesToPut = mutableListOf() + for (entry in rows) { + val name = entry.name.orEmpty().trim() + val normalizedName = normalizeName(name) + if (normalizedName.isBlank() || existingKeys.contains(normalizedName)) continue + existingKeys.add(normalizedName) + val entity = ExerciseEntity().apply { + uid = entry.id?.takeIf { it.isNotBlank() } ?: uid + this.name = name + this.normalizedName = normalizedName + primaryMuscle = toTitleCase(entry.primaryMuscle ?: entry.primaryMuscles?.firstOrNull() ?: "Other") + equipment = normalizeEquipment(entry.equipment) + category = normalizeCategory(entry) + isCustom = false + source = "built_in" + notes = entry.notes?.toString() ?: "" + createdAt = now + updatedAt = now + equipmentDetail = entry.equipmentDetail + apparatusJson = entry.apparatus?.let { json.encodeToString(it) } + trackingType = ExerciseTrackingTypeNormalizer.normalize( + name = name, + category = category, + equipment = equipment, + explicitTrackingType = entry.trackingType, + ) + isBodyweight = entry.isBodyweight == true + requiresExternalLoad = entry.requiresExternalLoad == true + movementPattern = entry.movementPattern + difficulty = entry.difficulty + aliasesJson = entry.aliases?.let { json.encodeToString(it) } + sourceTagsJson = entry.sourceTags?.let { json.encodeToString(it) } + } + exercisesToPut += entity + insertedByKey[normalizedName] = entity to entry + } + + exercisesToPut.chunked(BATCH_SIZE).forEach { exerciseBox.put(it) } + + val muscleRows = mutableListOf() + for ((_, pair) in insertedByKey) { + val (exercise, source) = pair + buildMuscleRows(source).forEach { muscleRow -> + muscleRows += ExerciseMuscleEntity().apply { + this.exercise.target = exercise + exerciseUid = exercise.uid + muscle = muscleRow.muscle + role = muscleRow.role + contributionFraction = muscleRow.contribution + createdAt = now + updatedAt = now + } + } + } + muscleRows.chunked(BATCH_SIZE).forEach { muscleBox.put(it) } + + settingsRepository.markExerciseSeedComplete() + repairExerciseTrackingTypes() + SeedResult(seeded = true, count = rows.size) + } + + suspend fun backfillExerciseMusclesIfNeeded(): BackfillResult = withContext(Dispatchers.IO) { + if (muscleBox.count() > 0) { + return@withContext BackfillResult(backfilled = false, reason = "already_populated") + } + + val libraryByName = loadPayload().exercises + .filter { !it.name.isNullOrBlank() } + .associateBy { normalizeName(it.name) } + + val allExercises = exerciseBox.all + val now = System.currentTimeMillis() + val muscleRows = mutableListOf() + for (exercise in allExercises) { + val sourceEntry = libraryByName[exercise.normalizedName] ?: continue + buildMuscleRows(sourceEntry).forEach { row -> + muscleRows += ExerciseMuscleEntity().apply { + this.exercise.target = exercise + exerciseUid = exercise.uid + muscle = row.muscle + role = row.role + contributionFraction = row.contribution + createdAt = now + updatedAt = now + } + } + } + + if (muscleRows.isEmpty()) return@withContext BackfillResult(backfilled = false, reason = "no_exercises_matched") + muscleRows.chunked(BATCH_SIZE).forEach { muscleBox.put(it) } + BackfillResult(backfilled = true, count = muscleRows.size) + } + + private fun loadPayload(): ExerciseSeedPayload { + val text = context.assets.open("exerciseLibrary.json").bufferedReader().use { it.readText() } + return json.decodeFromString(ExerciseSeedPayload.serializer(), text) + } + + private fun repairExerciseTrackingTypes(): Int { + val changed = exerciseBox.all.mapNotNull { exercise -> + val normalized = ExerciseTrackingTypeNormalizer.normalize( + name = exercise.name, + category = exercise.category, + equipment = exercise.equipment, + explicitTrackingType = exercise.trackingType, + ) + if (exercise.trackingType == normalized) null else { + exercise.trackingType = normalized + exercise.updatedAt = System.currentTimeMillis() + exercise + } + } + if (changed.isNotEmpty()) changed.chunked(BATCH_SIZE).forEach { exerciseBox.put(it) } + return changed.size + } + + private data class MuscleSeedRow(val muscle: String, val role: String, val contribution: Double) + + private fun buildMuscleRows(exercise: ExerciseSeedEntry): List { + val primary = (exercise.primaryMuscles?.filter { it.isNotBlank() } + ?: listOfNotNull(exercise.primaryMuscle?.takeIf { it.isNotBlank() })) + val secondary = exercise.secondaryMuscles?.filter { it.isNotBlank() }.orEmpty() + if (primary.isEmpty() && secondary.isEmpty()) return emptyList() + if (secondary.isEmpty()) { + val each = 1.0 / max(primary.size, 1) + return primary.map { MuscleSeedRow(toTitleCase(it), "primary", each) } + } + val primaryWeight = 0.7 / max(primary.size, 1) + val secondaryWeight = 0.3 / max(secondary.size, 1) + return primary.map { MuscleSeedRow(toTitleCase(it), "primary", primaryWeight) } + + secondary.map { MuscleSeedRow(toTitleCase(it), "secondary", secondaryWeight) } + } + + private fun toTitleCase(value: String?): String = value.orEmpty() + .trim() + .split(Regex("\\s+")) + .filter { it.isNotBlank() } + .joinToString(" ") { part -> + part.lowercase(Locale.ROOT).replaceFirstChar { ch -> ch.titlecase(Locale.ROOT) } + } + + // Uses the String overload of normalizeExerciseNameKey (underscores format, from ExerciseUiFilters) + // so that stored normalizedName values are consistent with what createCustomExercise stores + // and what the duplicate-name guard queries. + private fun normalizeName(value: String?): String = normalizeExerciseNameKey(value.orEmpty().trim()) + + private fun normalizeEquipment(value: String?): String { + val raw = value.orEmpty().trim() + return if (raw.isBlank()) "Other" else toTitleCase(raw) + } + + private fun normalizeCategory(exercise: ExerciseSeedEntry): String { + val fromCategory = exercise.category.orEmpty().lowercase(Locale.ROOT) + return when { + fromCategory.contains("cardio") || fromCategory.contains("conditioning") -> "cardio" + fromCategory.contains("stretch") || fromCategory.contains("mobility") -> "mobility" + else -> "strength" + } + } + + companion object { private const val BATCH_SIZE = 150 } +} diff --git a/app/src/main/java/com/ironlog/app/data/seed/ProgramTemplates.kt b/app/src/main/java/com/ironlog/app/data/seed/ProgramTemplates.kt new file mode 100644 index 0000000..23aaec8 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/data/seed/ProgramTemplates.kt @@ -0,0 +1,1381 @@ +package com.ironlog.app.data.seed + +import com.ironlog.app.data.model.FullPlanDay +import com.ironlog.app.data.model.FullPlanObject +import com.ironlog.app.data.model.PlanExerciseInput + +data class ProgramTemplate( + val id: String, + val name: String, + val category: String, + val description: String, + val days: List, + val difficulty: String = "Intermediate", + val durationWeeks: Int = 4, +) + +val PROGRAM_TEMPLATES: List = listOf( + ProgramTemplate( + id = "aesthetic-split-default", + name = "Aesthetic Split", + category = "AESTHETIC", + description = "4 days, cutting phase, focused on lats, side delts, abs, and legs", + days = listOf( + FullPlanDay("PUSH", "#FF4500", listOf( + PlanExerciseInput(name = "Incline Smith Press", sets = 4, reps = "8–10", restSeconds = 90, isWarmup = false, notes = "Full stretch at bottom, squeeze at top."), + PlanExerciseInput(name = "Cable Fly Low to High", sets = 3, reps = "12–15", restSeconds = 90, isWarmup = false, notes = "Cables at lowest point, pull upward in arc."), + PlanExerciseInput(name = "Cable Lateral Raise Single Arm", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = "3-second negative. Lead with elbow."), + PlanExerciseInput(name = "DB Lateral Raise", sets = 3, reps = "15–20", restSeconds = 90, isWarmup = false, notes = "Slight forward lean. Elbow leads."), + PlanExerciseInput(name = "Rope Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "Flare rope at bottom. Elbows pinned."), + PlanExerciseInput(name = "Single Arm Overhead Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "Long head stretch. Slow eccentric."), + PlanExerciseInput(name = "Weighted Cable Crunch", sets = 4, reps = "15–20", restSeconds = 90, isWarmup = false, notes = "Crunch into hips not knees.") + )), + FullPlanDay("PULL", "#0080FF", listOf( + PlanExerciseInput(name = "Weighted Pull-Up", sets = 4, reps = "6–8", restSeconds = 90, isWarmup = false, notes = "Best lat builder. Add weight progressively."), + PlanExerciseInput(name = "Single Arm DB Row", sets = 4, reps = "10–12", restSeconds = 90, isWarmup = false, notes = "Incline bench 30 degrees. Pull elbow back."), + PlanExerciseInput(name = "Single Arm Cable Pulldown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "Lie chest-down on incline bench."), + PlanExerciseInput(name = "DB Shrugs", sets = 4, reps = "15–20", restSeconds = 90, isWarmup = false, notes = "1-second hold at top."), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "Brachialis = thicker arm silhouette."), + PlanExerciseInput(name = "Incline DB Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "Full long head stretch. Builds the peak."), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 4, reps = "12–15", restSeconds = 90, isWarmup = false, notes = "Add ankle weights when easy.") + )), + FullPlanDay("LEGS", "#00C170", listOf( + PlanExerciseInput(name = "Hip 90/90 Stretch", sets = 2, reps = "60s each side", restSeconds = 90, isWarmup = true, notes = "Non-negotiable warmup."), + PlanExerciseInput(name = "Worlds Greatest Stretch", sets = 2, reps = "5 reps each side", restSeconds = 90, isWarmup = true, notes = "T-spine plus hip flexor plus hamstring."), + PlanExerciseInput(name = "Bulgarian Split Squat", sets = 4, reps = "8–10 each leg", restSeconds = 90, isWarmup = false, notes = "Front shin vertical. Go deep."), + PlanExerciseInput(name = "Romanian Deadlift", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = "Hip hinge equals running power."), + PlanExerciseInput(name = "Leg Press High Feet", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "High foot placement shifts load."), + PlanExerciseInput(name = "Lateral Band Walk", sets = 3, reps = "15 steps each way", restSeconds = 90, isWarmup = false, notes = "Lateral stability for cutting."), + PlanExerciseInput(name = "Single Leg Calf Raise", sets = 4, reps = "20", restSeconds = 90, isWarmup = false, notes = "On a step for full ROM."), + PlanExerciseInput(name = "Ankle Mobility Drill", sets = 2, reps = "60s each side", restSeconds = 90, isWarmup = true, notes = "Cooldown. Do not skip.") + )), + FullPlanDay("UPPER", "#A020F0", listOf( + PlanExerciseInput(name = "Weighted Pull-Up or Lat Pulldown", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = "Second lat session. Full stretch."), + PlanExerciseInput(name = "Cable Lateral Raise Single Arm", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = "Second hit this week. 3s negative."), + PlanExerciseInput(name = "DB Shrugs", sets = 3, reps = "20", restSeconds = 90, isWarmup = false, notes = "Push weight up from Pull day."), + PlanExerciseInput(name = "Preacher Curl", sets = 3, reps = "10–12", restSeconds = 90, isWarmup = false, notes = "No cheating possible."), + PlanExerciseInput(name = "Rope Overhead Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "Long head. Makes arms look big."), + PlanExerciseInput(name = "Weighted Cable Crunch", sets = 4, reps = "15–20", restSeconds = 90, isWarmup = false, notes = "Heavier than Push day."), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = "Add ankle weights."), + PlanExerciseInput(name = "Ab Wheel Rollout", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "Hardest ab exercise.") + )) + ), + ), + ProgramTemplate( + id = "full_body_beginner_3x", + name = "Full Body Beginner (3x/week)", + category = "BEGINNER", + description = "Simple full-body progression.", + days = listOf( + FullPlanDay("FULL BODY A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Bench Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 3, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("FULL BODY B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Deadlift", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 2, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("FULL BODY C", "#4C6FFF", listOf( + PlanExerciseInput(name = "Front Squat", sets = 3, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 2, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "minimalist_full_body_23", + name = "Minimalist Full Body (2-3 Day)", + category = "BEGINNER", + description = "Time-efficient full-body essentials.", + days = listOf( + FullPlanDay("MINIMAL A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Bench Press", sets = 3, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 3, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("MINIMAL B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Romanian Deadlift", sets = 3, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("MINIMAL A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Bench Press", sets = 3, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 3, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "upper_lower_beginner_4x", + name = "Upper / Lower Beginner (4x/week)", + category = "BEGINNER", + description = "Balanced upper/lower split.", + days = listOf( + FullPlanDay("UPPER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Pull-Up", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Calf Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("UPPER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Hack Squat", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "bodyweight_beginner", + name = "Bodyweight Beginner", + category = "BEGINNER", + description = "No-gym bodyweight basics.", + days = listOf( + FullPlanDay("CALI PUSH", "#4C6FFF", listOf( + PlanExerciseInput(name = "Push-Up", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Dips", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Push-Up", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 4, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Sit-Up", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Pull-Up", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CALI PULL", "#4C6FFF", listOf( + PlanExerciseInput(name = "Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Inverted Row", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Single-Leg Romanian Deadlift", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Reverse Crunch", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Push-Up", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CALI MIXED", "#4C6FFF", listOf( + PlanExerciseInput(name = "Bodyweight Squat", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Split Squat", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Glute Bridge", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Push-Up", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Pull-Up", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "upper_lower_strength_4x", + name = "Upper / Lower Strength Focus (4x/week)", + category = "STRENGTH", + description = "Power + volume blend.", + days = listOf( + FullPlanDay("UPPER POWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Weighted Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 3, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER POWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "4", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Deadlift", sets = 2, reps = "3", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("UPPER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Hack Squat", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "novice_barbell_strength", + name = "Novice Barbell Strength", + category = "STRENGTH", + description = "Linear barbell progression.", + days = listOf( + FullPlanDay("WORKOUT A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Bench Press", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("WORKOUT B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Deadlift", sets = 1, reps = "5", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("WORKOUT C", "#4C6FFF", listOf( + PlanExerciseInput(name = "Front Squat", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Barbell Press", sets = 3, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "powerbuilding_4_day", + name = "Powerbuilding 4-Day", + category = "STRENGTH", + description = "Strength first, size second.", + days = listOf( + FullPlanDay("UPPER POWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Weighted Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 3, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER POWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "4", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Deadlift", sets = 2, reps = "3", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("PUSH B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Barbell Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Dumbbell Shoulder Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Tricep Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LEGS B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Front Squat", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hack Squat", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "powerbuilding_5_day", + name = "Powerbuilding 5-Day", + category = "STRENGTH", + description = "Higher-frequency powerbuilding.", + days = listOf( + FullPlanDay("UPPER POWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Weighted Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 3, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER POWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "4", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Deadlift", sets = 2, reps = "3", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("PUSH A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Tricep Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("PULL B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Weighted Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Back Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LEGS B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Front Squat", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hack Squat", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "pure_hypertrophy_5_day", + name = "Pure Hypertrophy 5-Day", + category = "HYPERTROPHY", + description = "High-volume growth split.", + days = listOf( + FullPlanDay("PUSH A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Tricep Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("PULL A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Deadlift", sets = 4, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LEGS A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Calf Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("UPPER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Hack Squat", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "ppl_moderate_5x", + name = "Push / Pull / Legs Moderate (5x/week)", + category = "HYPERTROPHY", + description = "Moderate-frequency PPL.", + days = listOf( + FullPlanDay("PUSH A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Tricep Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("PULL A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Deadlift", sets = 4, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LEGS A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Calf Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("PUSH B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Barbell Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Dumbbell Shoulder Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Tricep Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("PULL B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Weighted Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Back Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "ppl_classic_6x", + name = "Push / Pull / Legs Classic (6x/week)", + category = "HYPERTROPHY", + description = "Classic high-frequency PPL.", + days = listOf( + FullPlanDay("PUSH A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Tricep Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("PULL A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Deadlift", sets = 4, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LEGS A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Calf Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("PUSH B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Barbell Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Dumbbell Shoulder Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Tricep Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("PULL B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Weighted Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Back Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LEGS B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Front Squat", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hack Squat", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "bro_split_5x", + name = "Bro Split (5x/week)", + category = "HYPERTROPHY", + description = "Single-muscle focus days.", + days = listOf( + FullPlanDay("CHEST", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Skull Crusher", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("BACK", "#4C6FFF", listOf( + PlanExerciseInput(name = "Weighted Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("SHOULDERS", "#4C6FFF", listOf( + PlanExerciseInput(name = "Overhead Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Dumbbell Shoulder Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 5, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("ARMS", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Curl", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Tricep Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "EZ-Bar Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LEGS A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Calf Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "arnold_split_6x", + name = "Arnold Split (6x/week)", + category = "HYPERTROPHY", + description = "Classic high-volume pairing split.", + days = listOf( + FullPlanDay("CHEST + BACK (HEAVY)", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("SHOULDERS + ARMS (HEAVY)", "#4C6FFF", listOf( + PlanExerciseInput(name = "Overhead Press", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Curl", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Skull Crusher", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LEGS (HEAVY)", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CHEST + BACK (VOLUME)", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("SHOULDERS + ARMS (VOLUME)", "#4C6FFF", listOf( + PlanExerciseInput(name = "Dumbbell Shoulder Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Tricep Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LEGS (VOLUME)", "#4C6FFF", listOf( + PlanExerciseInput(name = "Hack Squat", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "mens_aesthetic_v_taper", + name = "Men's Aesthetic V-Taper Program", + category = "AESTHETIC", + description = "Shoulders and lats emphasis.", + days = listOf( + FullPlanDay("UPPER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Calf Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("SHOULDERS", "#4C6FFF", listOf( + PlanExerciseInput(name = "Overhead Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Dumbbell Shoulder Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 5, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("BACK", "#4C6FFF", listOf( + PlanExerciseInput(name = "Weighted Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("ARMS", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Curl", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Tricep Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "EZ-Bar Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "lean_physique_recomp", + name = "Lean Physique Recomp Program", + category = "AESTHETIC", + description = "Lifting + conditioning recomposition.", + days = listOf( + FullPlanDay("UPPER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Pull-Up", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Calf Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CONDITIONING", "#4C6FFF", listOf( + PlanExerciseInput(name = "Air Bike", sets = 6, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Reverse Crunch", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 4, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Running", sets = 4, reps = "400", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Spider Crawl", sets = 4, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("UPPER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Hack Squat", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "upper_body_bias_program", + name = "Upper Body Bias Program", + category = "AESTHETIC", + description = "Upper-focused with lower maintenance.", + days = listOf( + FullPlanDay("UPPER POWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Weighted Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 3, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Calf Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("UPPER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("SHOULDERS", "#4C6FFF", listOf( + PlanExerciseInput(name = "Overhead Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Dumbbell Shoulder Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 5, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "glute_legs_focus_45", + name = "Glute & Legs Focus (4-5x/week)", + category = "LOWER_BODY_GLUTES", + description = "Lower-body dominant volume.", + days = listOf( + FullPlanDay("GLUTE + QUAD", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 2, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("UPPER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("GLUTE + HAM", "#4C6FFF", listOf( + PlanExerciseInput(name = "Romanian Deadlift", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("GLUTE PUMP", "#4C6FFF", listOf( + PlanExerciseInput(name = "Hack Squat", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 2, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "womens_lower_body_focus", + name = "Women's Lower Body Focus", + category = "LOWER_BODY_GLUTES", + description = "Lower-body emphasis with upper support.", + days = listOf( + FullPlanDay("LOWER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Calf Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("UPPER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Hack Squat", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LEGS B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Front Squat", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hack Squat", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "athletic_legs_program", + name = "Athletic Legs Program", + category = "LOWER_BODY_GLUTES", + description = "Power + conditioning lower split.", + days = listOf( + FullPlanDay("LOWER POWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "4", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Deadlift", sets = 2, reps = "3", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CONDITIONING", "#4C6FFF", listOf( + PlanExerciseInput(name = "Air Bike", sets = 6, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Reverse Crunch", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 4, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Running", sets = 4, reps = "400", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Spider Crawl", sets = 4, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LEGS A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Calf Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("UPPER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Pull-Up", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "arm_specialization_block", + name = "Arm Specialization Block", + category = "SPECIALIZATION", + description = "Extra direct arm volume.", + days = listOf( + FullPlanDay("ARMS", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Curl", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Tricep Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "EZ-Bar Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("UPPER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Pull-Up", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("PULL B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Weighted Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Back Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("ARMS", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Curl", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Tricep Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "EZ-Bar Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Calf Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "chest_specialization_block", + name = "Chest Specialization Block", + category = "SPECIALIZATION", + description = "Chest-priority frequency block.", + days = listOf( + FullPlanDay("CHEST", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Skull Crusher", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("UPPER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Pull-Up", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Calf Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CHEST", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Skull Crusher", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("UPPER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "back_width_thickness_block", + name = "Back Width + Thickness Block", + category = "SPECIALIZATION", + description = "Dual-focus back growth block.", + days = listOf( + FullPlanDay("BACK", "#4C6FFF", listOf( + PlanExerciseInput(name = "Weighted Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("PULL A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Deadlift", sets = 4, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Calf Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("BACK", "#4C6FFF", listOf( + PlanExerciseInput(name = "Weighted Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("ARMS", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Curl", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Tricep Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "EZ-Bar Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "shoulder_specialization_block", + name = "Shoulder Specialization Block", + category = "SPECIALIZATION", + description = "High-frequency delt block.", + days = listOf( + FullPlanDay("SHOULDERS", "#4C6FFF", listOf( + PlanExerciseInput(name = "Overhead Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Dumbbell Shoulder Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 5, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("UPPER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Pull-Up", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Calf Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("SHOULDERS", "#4C6FFF", listOf( + PlanExerciseInput(name = "Overhead Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Dumbbell Shoulder Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 5, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("PUSH B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Barbell Press", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Dumbbell Shoulder Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Tricep Extension", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Cable Fly", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "home_dumbbell_only_program", + name = "Home Dumbbell Only Program", + category = "HOME_MINIMAL", + description = "No-machine home split.", + days = listOf( + FullPlanDay("HOME UPPER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Dumbbell Bench Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Dumbbell Shoulder Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "One-Arm Dumbbell Row", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("HOME LOWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Goblet Squat", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 3, reps = "60", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("HOME UPPER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Dumbbell Bench Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Dumbbell Shoulder Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "One-Arm Dumbbell Row", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("HOME LOWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Goblet Squat", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 3, reps = "60", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "home_dumbbell_bench_program", + name = "Home Dumbbell + Bench Program", + category = "HOME_MINIMAL", + description = "Home split with bench options.", + days = listOf( + FullPlanDay("HOME PUSH", "#4C6FFF", listOf( + PlanExerciseInput(name = "Dumbbell Bench Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Dumbbell Shoulder Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("HOME LOWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Goblet Squat", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 3, reps = "60", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("HOME PULL", "#4C6FFF", listOf( + PlanExerciseInput(name = "One-Arm Dumbbell Row", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "One-Arm Dumbbell Row", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Back Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("HOME LOWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Goblet Squat", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Romanian Deadlift", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 3, reps = "60", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "resistance_band_only_program", + name = "Resistance Band Only Program", + category = "HOME_MINIMAL", + description = "Portable resistance-band routine.", + days = listOf( + FullPlanDay("BAND UPPER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Push-Up", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Band Row", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Push-Up", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Band Curl", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("BAND LOWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Band Squat", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Band Squat", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Glute Bridge", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Band Calf Raise", sets = 4, reps = "20", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("BAND UPPER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Push-Up", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Band Row", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Push-Up", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Band Curl", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("BAND LOWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Band Squat", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Band Squat", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Glute Bridge", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Band Calf Raise", sets = 4, reps = "20", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "calisthenics_strength_base", + name = "Calisthenics Strength Base", + category = "BODYWEIGHT_CALISTHENICS", + description = "Bodyweight strength progression.", + days = listOf( + FullPlanDay("CALI PUSH", "#4C6FFF", listOf( + PlanExerciseInput(name = "Push-Up", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Dips", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Push-Up", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 4, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Sit-Up", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Pull-Up", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CALI PULL", "#4C6FFF", listOf( + PlanExerciseInput(name = "Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Inverted Row", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Single-Leg Romanian Deadlift", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Reverse Crunch", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Push-Up", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CALI MIXED", "#4C6FFF", listOf( + PlanExerciseInput(name = "Bodyweight Squat", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Split Squat", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Glute Bridge", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Push-Up", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Pull-Up", sets = 4, reps = "8", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CALI PULL", "#4C6FFF", listOf( + PlanExerciseInput(name = "Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Inverted Row", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Single-Leg Romanian Deadlift", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Reverse Crunch", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Push-Up", sets = 4, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "fat_loss_conditioning_lifting_hybrid", + name = "Fat Loss Conditioning + Lifting Hybrid", + category = "FAT_LOSS_CONDITIONING", + description = "Alternating lift and conditioning days.", + days = listOf( + FullPlanDay("FULL BODY A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Bench Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 3, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CONDITIONING", "#4C6FFF", listOf( + PlanExerciseInput(name = "Air Bike", sets = 6, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Reverse Crunch", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 4, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Running", sets = 4, reps = "400", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Spider Crawl", sets = 4, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("FULL BODY B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Deadlift", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 2, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CONDITIONING", "#4C6FFF", listOf( + PlanExerciseInput(name = "Air Bike", sets = 6, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Reverse Crunch", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 4, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Running", sets = 4, reps = "400", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Spider Crawl", sets = 4, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("UPPER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "interference_aware_recomp", + name = "Interference-Aware Fat Loss / Recomp", + category = "FAT_LOSS_CONDITIONING", + description = "Recovery-aware lifting and conditioning split.", + days = listOf( + FullPlanDay("UPPER A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Pull-Up", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CONDITIONING", "#4C6FFF", listOf( + PlanExerciseInput(name = "Air Bike", sets = 6, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Reverse Crunch", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 4, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Running", sets = 4, reps = "400", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Spider Crawl", sets = 4, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Hack Squat", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Extension", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 4, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("UPPER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CONDITIONING", "#4C6FFF", listOf( + PlanExerciseInput(name = "Air Bike", sets = 6, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Reverse Crunch", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 4, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Running", sets = 4, reps = "400", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Spider Crawl", sets = 4, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "lift_3day_cardio_2day", + name = "3-Day Lift + 2-Day Cardio Plan", + category = "FAT_LOSS_CONDITIONING", + description = "Simple split with cardio built in.", + days = listOf( + FullPlanDay("FULL BODY A", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Bench Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 3, reps = "45", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CONDITIONING", "#4C6FFF", listOf( + PlanExerciseInput(name = "Air Bike", sets = 6, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Reverse Crunch", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 4, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Running", sets = 4, reps = "400", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Spider Crawl", sets = 4, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("FULL BODY B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Deadlift", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "8", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 2, reps = "15", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CONDITIONING", "#4C6FFF", listOf( + PlanExerciseInput(name = "Air Bike", sets = 6, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Reverse Crunch", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 4, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Running", sets = 4, reps = "400", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Spider Crawl", sets = 4, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("FULL BODY C", "#4C6FFF", listOf( + PlanExerciseInput(name = "Front Squat", sets = 3, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Seated Cable Row", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 2, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ), + ProgramTemplate( + id = "athletic_conditioning_program", + name = "Athletic Conditioning Program", + category = "FAT_LOSS_CONDITIONING", + description = "Athletic engine + strength emphasis.", + days = listOf( + FullPlanDay("UPPER POWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Bench Press", sets = 4, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Weighted Pull-Up", sets = 4, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Overhead Press", sets = 3, reps = "5", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Barbell Row", sets = 3, reps = "6", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CONDITIONING", "#4C6FFF", listOf( + PlanExerciseInput(name = "Air Bike", sets = 6, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Reverse Crunch", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 4, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Running", sets = 4, reps = "400", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Spider Crawl", sets = 4, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("LOWER POWER", "#4C6FFF", listOf( + PlanExerciseInput(name = "Barbell Squat", sets = 4, reps = "4", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Deadlift", sets = 2, reps = "3", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Leg Curl", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Standing Calf Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Walking Lunge", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hip Thrust", sets = 3, reps = "10", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("UPPER B", "#4C6FFF", listOf( + PlanExerciseInput(name = "Incline Dumbbell Press", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lat Pulldown", sets = 4, reps = "10", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Lateral Raise", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Face Pull", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hammer Curl", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Tricep Pushdown", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )), + FullPlanDay("CONDITIONING", "#4C6FFF", listOf( + PlanExerciseInput(name = "Air Bike", sets = 6, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Reverse Crunch", sets = 3, reps = "15", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Plank", sets = 4, reps = "45", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Running", sets = 4, reps = "400", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Spider Crawl", sets = 4, reps = "20", restSeconds = 90, isWarmup = false, notes = ""), + PlanExerciseInput(name = "Hanging Leg Raise", sets = 3, reps = "12", restSeconds = 90, isWarmup = false, notes = "") + )) + ), + ) +) + +fun ProgramTemplate.toPlanObject(): FullPlanObject = FullPlanObject( + name = name, + goal = category, + description = description, + days = days, +) diff --git a/app/src/main/java/com/ironlog/app/domain/ai/ExerciseResolutionEngine.kt b/app/src/main/java/com/ironlog/app/domain/ai/ExerciseResolutionEngine.kt new file mode 100644 index 0000000..1dbc7b6 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/ai/ExerciseResolutionEngine.kt @@ -0,0 +1,234 @@ +package com.ironlog.app.domain.ai + +import com.ironlog.app.data.model.LegacyExerciseShape +import com.ironlog.app.util.normalizeExerciseNameKey + +enum class ResolutionStatus { + MATCHED, + NEEDS_REVIEW, + UNRESOLVED, +} + +data class ExerciseResolution( + val status: ResolutionStatus, + val matched: LegacyExerciseShape? = null, + val confidence: Int = 0, + val reason: String = "", + val topCandidates: List> = emptyList(), +) + +object ExerciseResolutionEngine { + private const val STRONG_MATCH_THRESHOLD = 88 + private const val REVIEW_MATCH_THRESHOLD = 52 + private val MUSCLE_HINTS = mapOf( + "chest" to setOf("chest", "pec", "pecs", "pectoral"), + "back" to setOf("back", "lat", "lats", "row"), + "shoulders" to setOf("shoulder", "shoulders", "delt", "delts"), + "biceps" to setOf("bicep", "biceps", "curl"), + "triceps" to setOf("tricep", "triceps", "pushdown"), + "quads" to setOf("quad", "quads", "thigh"), + "hamstrings" to setOf("hamstring", "hamstrings"), + "glutes" to setOf("glute", "glutes"), + "calves" to setOf("calf", "calves"), + "core" to setOf("core", "abs", "ab", "abdominal"), + "forearms" to setOf("forearm", "forearms", "grip"), + "traps" to setOf("trap", "traps", "shrug"), + "cardio" to setOf("cardio", "run", "bike", "treadmill", "rower"), + ) + private val EQUIPMENT_HINTS = mapOf( + "barbell" to setOf("barbell", "bb"), + "dumbbell" to setOf("dumbbell", "db"), + "cable" to setOf("cable"), + "machine" to setOf("machine", "smith"), + "bodyweight" to setOf("bodyweight", "bw", "pullup", "pushup", "dip"), + "band" to setOf("band"), + "kettlebell" to setOf("kettlebell", "kb"), + ) + private val MOVEMENT_HINTS = mapOf( + "push" to setOf("push", "press"), + "pull" to setOf("pull", "row"), + "hinge" to setOf("hinge", "deadlift", "rdl"), + "squat" to setOf("squat", "legpress"), + "lunge" to setOf("lunge", "split squat"), + "isolation" to setOf("curl", "extension", "raise", "fly"), + "conditioning" to setOf("conditioning", "metcon", "interval"), + ) + + fun resolve(name: String, all: List): ExerciseResolution { + val normalized = normalizeExerciseNameKey(name) + if (normalized.isBlank()) { + return ExerciseResolution( + status = ResolutionStatus.UNRESOLVED, + confidence = 0, + reason = "Blank exercise name", + ) + } + + val exact = all.firstOrNull { normalizeExerciseNameKey(it.name) == normalized } + if (exact != null) { + return ExerciseResolution( + status = ResolutionStatus.MATCHED, + matched = exact, + confidence = 100, + reason = "Exact normalized name", + ) + } + + val aliasExact = all.firstOrNull { ex -> ex.aliases.any { normalizeExerciseNameKey(it) == normalized } } + if (aliasExact != null) { + return ExerciseResolution( + status = ResolutionStatus.MATCHED, + matched = aliasExact, + confidence = 98, + reason = "Alias exact match", + ) + } + + val ranked = all.map { candidate -> + val score = scoreSimilarity(normalized, candidate) + candidate to score + // Stable deterministic sort: primary by score desc, secondary by name asc so ties are repeatable. + }.sortedWith(compareByDescending> { it.second }.thenBy { it.first.name }) + + val best = ranked.firstOrNull() ?: return ExerciseResolution( + status = ResolutionStatus.UNRESOLVED, + confidence = 0, + reason = "No library candidates", + ) + val top = ranked.take(6) + + val bestCandidate = best.first + val bestScore = best.second + return when { + bestScore >= STRONG_MATCH_THRESHOLD -> ExerciseResolution( + status = ResolutionStatus.MATCHED, + matched = bestCandidate, + confidence = bestScore, + reason = "High-confidence fuzzy match", + topCandidates = top, + ) + + bestScore >= REVIEW_MATCH_THRESHOLD -> ExerciseResolution( + status = ResolutionStatus.NEEDS_REVIEW, + matched = bestCandidate, + confidence = bestScore, + reason = "Ambiguous match candidate", + topCandidates = top, + ) + + else -> ExerciseResolution( + status = ResolutionStatus.UNRESOLVED, + confidence = bestScore, + reason = "Low similarity", + topCandidates = top, + ) + } + } + + private fun scoreSimilarity(input: String, candidate: LegacyExerciseShape): Int { + val candidateName = normalizeExerciseNameKey(candidate.name) + val inputTokens = tokenize(input).toSet() + val candidateTokens = tokenize(candidateName).toSet() + val nameTokenScore = tokenJaccardScore(input, candidateName) + val editScore = editSimilarityScore(input, candidateName) + // Substring / prefix bonuses. + val containsBonus = when { + input == candidateName -> 15 + (input.contains(candidateName) || candidateName.contains(input)) && + kotlin.math.abs(input.length - candidateName.length) <= 6 -> 10 + else -> 0 + } + // Prefix token bonus: reward when the input tokens are a leading subset of the candidate tokens. + val prefixTokenBonus = run { + val cTokenList = tokenize(candidateName) + val iTokenList = tokenize(input) + if (iTokenList.isNotEmpty() && cTokenList.size >= iTokenList.size && + cTokenList.take(iTokenList.size) == iTokenList) 8 else 0 + } + val extraInputPenalty = ((inputTokens - candidateTokens).size * 3).coerceAtMost(9) + val extraCandidatePenalty = ((candidateTokens - inputTokens).size * 2).coerceAtMost(6) + val typoTokenBonus = fuzzyTokenBonus(inputTokens, candidateTokens) + val aliasTokenScore = candidate.aliases.maxOfOrNull { + tokenJaccardScore(input, normalizeExerciseNameKey(it)).coerceAtLeast(editSimilarityScore(input, normalizeExerciseNameKey(it))) + } ?: 0 + val hintBonus = hintBonusScore(input, candidate) + val base = ((nameTokenScore * 0.52) + (editScore * 0.48)).toInt() + val weighted = base + containsBonus + prefixTokenBonus + hintBonus + typoTokenBonus - extraInputPenalty - extraCandidatePenalty + return weighted.coerceAtLeast(aliasTokenScore + hintBonus).coerceIn(0, 100) + } + + private fun tokenJaccardScore(a: String, b: String): Int { + val ta = tokenize(a).toSet() + val tb = tokenize(b).toSet() + if (ta.isEmpty() || tb.isEmpty()) return 0 + val overlap = ta.intersect(tb).size.toDouble() + val union = ta.union(tb).size.toDouble() + return ((overlap / union) * 100.0).toInt().coerceIn(0, 100) + } + + private fun editSimilarityScore(a: String, b: String): Int { + if (a.isBlank() || b.isBlank()) return 0 + val dist = damerauLevenshteinDistance(a, b) + val maxLen = maxOf(a.length, b.length).coerceAtLeast(1) + return ((1.0 - (dist.toDouble() / maxLen.toDouble())) * 100.0).toInt().coerceIn(0, 100) + } + + // Optimal-string-alignment variant; good typo tolerance for adjacent swaps ("teh" -> "the"). + private fun damerauLevenshteinDistance(a: String, b: String): Int { + val m = a.length + val n = b.length + if (m == 0) return n + if (n == 0) return m + val dp = Array(m + 1) { IntArray(n + 1) } + for (i in 0..m) dp[i][0] = i + for (j in 0..n) dp[0][j] = j + for (i in 1..m) { + for (j in 1..n) { + val cost = if (a[i - 1] == b[j - 1]) 0 else 1 + var best = minOf( + dp[i - 1][j] + 1, + dp[i][j - 1] + 1, + dp[i - 1][j - 1] + cost, + ) + if (i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1]) { + best = minOf(best, dp[i - 2][j - 2] + 1) + } + dp[i][j] = best + } + } + return dp[m][n] + } + + private fun hintBonusScore(input: String, candidate: LegacyExerciseShape): Int { + val inputTokens = tokenize(input).toSet() + var bonus = 0 + val muscleKey = candidate.primaryMuscle?.lowercase()?.trim() + if (!muscleKey.isNullOrBlank()) { + val hints = MUSCLE_HINTS[muscleKey] + if (!hints.isNullOrEmpty() && hints.any { it in inputTokens }) bonus += 8 + } + val equipKey = candidate.equipment.lowercase().trim() + val equipHints = EQUIPMENT_HINTS[equipKey] + if (!equipHints.isNullOrEmpty() && equipHints.any { it in inputTokens }) bonus += 7 + val movementKey = candidate.movementPattern?.lowercase()?.trim() + val movementHints = movementKey?.let { MOVEMENT_HINTS[it] } + if (!movementHints.isNullOrEmpty() && movementHints.any { it in inputTokens }) bonus += 6 + return bonus + } + + private fun tokenize(value: String): List = value + .split(Regex("[\\s_\\-]+")) + .map { it.trim() } + .filter { it.isNotBlank() } + + private fun fuzzyTokenBonus(inputTokens: Set, candidateTokens: Set): Int { + if (inputTokens.isEmpty() || candidateTokens.isEmpty()) return 0 + var bonus = 0 + for (token in inputTokens) { + val best = candidateTokens.maxOfOrNull { editSimilarityScore(token, it) } ?: 0 + if (best >= 82) bonus += 5 + else if (best >= 70) bonus += 3 + } + return bonus.coerceAtMost(14) + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/ai/SmartPlanStructuringEngine.kt b/app/src/main/java/com/ironlog/app/domain/ai/SmartPlanStructuringEngine.kt new file mode 100644 index 0000000..fa95014 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/ai/SmartPlanStructuringEngine.kt @@ -0,0 +1,211 @@ +package com.ironlog.app.domain.ai + +import com.ironlog.app.data.model.FullPlanDay +import com.ironlog.app.data.model.FullPlanObject +import com.ironlog.app.data.model.LegacyExerciseShape +import com.ironlog.app.data.model.PlanExerciseInput +import com.ironlog.app.util.normalizeExerciseNameKey +import kotlin.math.max + +data class SmartPlanStructuringResult( + val plan: FullPlanObject, + val notes: List, + val insertedWarmupRows: Int, + val suggestedSupersetRows: Int, + val adjustedRestRows: Int, +) + +private data class ExerciseProfile( + val trackingType: String, + val movementPattern: String?, + val category: String, + val equipment: String, + val primaryMuscle: String?, +) + +object SmartPlanStructuringEngine { + private val COMPOUND_PATTERNS = setOf("push", "pull", "squat", "hinge", "lunge", "carry") + private val SUPERSET_FRIENDLY_PATTERNS = setOf("isolation", "push", "pull") + private val SUPERSET_FRIENDLY_EQUIPMENT = setOf("cable", "dumbbell", "machine", "band") + + fun structure(plan: FullPlanObject, library: List): SmartPlanStructuringResult { + val byId = library.associateBy { it.id } + val byName = library.associateBy { normalizeExerciseNameKey(it.name) } + + var warmupAdds = 0 + var supersetAdds = 0 + var restAdjustments = 0 + val notes = mutableListOf() + + val newDays = plan.days.map { day -> + val baseRows = day.exercises.toMutableList() + val withWarmups = mutableListOf() + val warmedCompounds = mutableSetOf() + + baseRows.forEach { row -> + val profile = resolveProfile(row, byId, byName) + val key = row.exerciseId ?: normalizeExerciseNameKey(row.name.orEmpty()) + val isCompound = isCompound(profile) + val isWarmup = row.isWarmup == true + val isTime = isTimeBased(profile, row) + + if (isCompound && !isWarmup && !isTime && !warmedCompounds.contains(key)) { + val hasWarmupAlready = baseRows.any { candidate -> + (candidate.exerciseId == row.exerciseId || normalizeExerciseNameKey(candidate.name.orEmpty()) == normalizeExerciseNameKey(row.name.orEmpty())) && + candidate.isWarmup == true + } + if (!hasWarmupAlready) { + withWarmups += row.copy( + sets = 2, + reps = if (isTime) "45" else "8-10", + restSeconds = 60, + supersetGroup = null, + isWarmup = true, + notes = mergeNote(row.notes, "Auto warmup"), + ) + warmupAdds++ + } + warmedCompounds += key + } + + val normalizedRest = normalizeRest(row, profile) + if (normalizedRest != (row.restSeconds ?: 0)) { + restAdjustments++ + } + withWarmups += row.copy(restSeconds = normalizedRest) + } + + val (withSupersets, daySupersetAdds) = applySupersetSuggestions(withWarmups, byId, byName) + supersetAdds += daySupersetAdds + + FullPlanDay( + name = day.name, + color = day.color, + exercises = withSupersets, + ) + } + + if (warmupAdds > 0) notes += "Auto-added $warmupAdds warmup row(s) for first heavy compounds." + if (supersetAdds > 0) notes += "Suggested supersets for $supersetAdds exercise row(s)." + if (restAdjustments > 0) notes += "Normalized rest on $restAdjustments row(s) for better pacing." + + return SmartPlanStructuringResult( + plan = plan.copy(days = newDays), + notes = notes, + insertedWarmupRows = warmupAdds, + suggestedSupersetRows = supersetAdds, + adjustedRestRows = restAdjustments, + ) + } + + private fun applySupersetSuggestions( + rows: List, + byId: Map, + byName: Map, + ): Pair, Int> { + val out = rows.toMutableList() + var counter = 0 + var groupIndex = 0 + var i = 0 + while (i < out.lastIndex) { + val a = out[i] + val b = out[i + 1] + if (canSuggestSuperset(a, b, byId, byName)) { + val group = ('A'.code + (groupIndex % 26)).toChar().toString() + out[i] = a.copy(supersetGroup = group) + out[i + 1] = b.copy(supersetGroup = group) + counter += 2 + groupIndex++ + i += 2 + continue + } + i++ + } + return out to counter + } + + private fun canSuggestSuperset( + a: PlanExerciseInput, + b: PlanExerciseInput, + byId: Map, + byName: Map, + ): Boolean { + if (a.isWarmup == true || b.isWarmup == true) return false + if (!a.supersetGroup.isNullOrBlank() || !b.supersetGroup.isNullOrBlank()) return false + val pa = resolveProfile(a, byId, byName) + val pb = resolveProfile(b, byId, byName) + if (isTimeBased(pa, a) || isTimeBased(pb, b)) return false + if (isCompound(pa) || isCompound(pb)) return false + val repA = extractRepFloor(a.reps) + val repB = extractRepFloor(b.reps) + if (repA < 8 || repB < 8) return false + val pattA = pa?.movementPattern?.lowercase() + val pattB = pb?.movementPattern?.lowercase() + val eqA = pa?.equipment?.lowercase().orEmpty() + val eqB = pb?.equipment?.lowercase().orEmpty() + val patOk = (pattA in SUPERSET_FRIENDLY_PATTERNS || eqA in SUPERSET_FRIENDLY_EQUIPMENT) && + (pattB in SUPERSET_FRIENDLY_PATTERNS || eqB in SUPERSET_FRIENDLY_EQUIPMENT) + if (!patOk) return false + val mA = pa?.primaryMuscle?.lowercase().orEmpty() + val mB = pb?.primaryMuscle?.lowercase().orEmpty() + return mA.isNotBlank() && mB.isNotBlank() && mA != mB + } + + private fun normalizeRest(row: PlanExerciseInput, profile: ExerciseProfile?): Int { + val current = row.restSeconds ?: 0 + if (row.isWarmup == true) { + return if (current > 0) current.coerceIn(45, 90) else 60 + } + if (isTimeBased(profile, row)) { + return if (current > 0) current.coerceIn(30, 75) else 45 + } + return if (isCompound(profile)) { + if (current > 0) current.coerceIn(120, 210) else 150 + } else { + if (current > 0) current.coerceIn(60, 120) else 75 + } + } + + private fun isCompound(profile: ExerciseProfile?): Boolean { + val pattern = profile?.movementPattern?.lowercase() + return pattern in COMPOUND_PATTERNS + } + + private fun isTimeBased(profile: ExerciseProfile?, row: PlanExerciseInput): Boolean { + val tracking = profile?.trackingType?.lowercase().orEmpty() + if (tracking.startsWith("duration")) return true + val reps = row.reps.orEmpty().lowercase() + return "sec" in reps || "min" in reps || reps.endsWith("s") + } + + private fun resolveProfile( + row: PlanExerciseInput, + byId: Map, + byName: Map, + ): ExerciseProfile? { + val matched = row.exerciseId?.let(byId::get) ?: byName[normalizeExerciseNameKey(row.name.orEmpty())] + return matched?.let { + ExerciseProfile( + trackingType = it.trackingType, + movementPattern = it.movementPattern, + category = it.category, + equipment = it.equipment, + primaryMuscle = it.primaryMuscle ?: it.primaryMuscles.firstOrNull(), + ) + } + } + + private fun extractRepFloor(raw: String?): Int { + val src = raw.orEmpty() + val ints = Regex("\\d+").findAll(src).map { it.value.toIntOrNull() ?: 0 }.toList() + if (ints.isEmpty()) return 0 + return max(0, ints.minOrNull() ?: 0) + } + + private fun mergeNote(current: String?, extra: String): String { + val base = current.orEmpty().trim() + if (base.isBlank()) return extra + if (base.contains(extra, ignoreCase = true)) return base + return "$base | $extra" + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/badges/BadgeDefinitions.kt b/app/src/main/java/com/ironlog/app/domain/badges/BadgeDefinitions.kt new file mode 100644 index 0000000..3a98240 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/badges/BadgeDefinitions.kt @@ -0,0 +1,146 @@ +package com.ironlog.app.domain.badges + +enum class BadgeTier { BRONZE, SILVER, GOLD, BLUE } + +/** + * Snapshot of user progress used to evaluate badge unlock conditions. + * Extend this as new metrics are tracked. + */ +data class AppStats( + val totalWorkouts: Int = 0, + val currentStreak: Int = 0, + val daysSinceFirstWorkout: Int = 0, + val totalVolumeKg: Double = 0.0, + val hasLoggedPR: Boolean = false, + val usedRestTimer: Boolean = false, + val createdPlan: Boolean = false, + val cloudAiActivated: Boolean = false, + val goalModesUsed: Set = emptySet(), + val currentRank: String = "E", + val weeksConsistent: Int = 0, + val consecutiveProgressionWorkouts: Int = 0, +) + +/** + * Describes an achievement badge. + * Add new badges to [BadgeDefinitions.all] — the UI reads this list at runtime. + * [iconResName] is the drawable resource name (e.g. "ic_badge_dumbbell"). + */ +data class BadgeDefinition( + val id: String, + val title: String, + val description: String, + val tier: BadgeTier, + val iconResName: String, + val unlockCondition: (AppStats) -> Boolean, +) + +object BadgeDefinitions { + + val all: List = listOf( + // ── BRONZE ──────────────────────────────────────────────────────────── + BadgeDefinition( + id = "first_workout", title = "Iron Initiate", + description = "Complete your first workout", tier = BadgeTier.BRONZE, + iconResName = "ic_badge_dumbbell", + unlockCondition = { it.totalWorkouts >= 1 }, + ), + BadgeDefinition( + id = "streak_3", title = "Spark", + description = "Achieve a 3-day workout streak", tier = BadgeTier.BRONZE, + iconResName = "ic_badge_flame", + unlockCondition = { it.currentStreak >= 3 }, + ), + BadgeDefinition( + id = "first_rest_timer", title = "Patience", + description = "Use the rest timer for the first time", tier = BadgeTier.BRONZE, + iconResName = "ic_badge_hourglass", + unlockCondition = { it.usedRestTimer }, + ), + BadgeDefinition( + id = "first_plan", title = "Architect", + description = "Create your first training plan", tier = BadgeTier.BRONZE, + iconResName = "ic_badge_twin_dumbbells", + unlockCondition = { it.createdPlan }, + ), + // ── SILVER ──────────────────────────────────────────────────────────── + BadgeDefinition( + id = "workouts_10", title = "Charged", + description = "Complete 10 workouts", tier = BadgeTier.SILVER, + iconResName = "ic_badge_lightning", + unlockCondition = { it.totalWorkouts >= 10 }, + ), + BadgeDefinition( + id = "consistency_4w", title = "Clockwork", + description = "Train consistently for 4 weeks", tier = BadgeTier.SILVER, + iconResName = "ic_badge_calendar", + unlockCondition = { it.weeksConsistent >= 4 }, + ), + BadgeDefinition( + id = "first_pr", title = "Muscle Memory", + description = "Log your first personal record", tier = BadgeTier.SILVER, + iconResName = "ic_badge_flexed_arm", + unlockCondition = { it.hasLoggedPR }, + ), + BadgeDefinition( + id = "ai_activated", title = "Augmented", + description = "Activate Cloud AI coaching", tier = BadgeTier.SILVER, + iconResName = "ic_badge_atom", + unlockCondition = { it.cloudAiActivated }, + ), + BadgeDefinition( + id = "progressive_streak", title = "Growth Curve", + description = "Progress in 4 consecutive workouts", tier = BadgeTier.SILVER, + iconResName = "ic_badge_chart", + unlockCondition = { it.consecutiveProgressionWorkouts >= 4 }, + ), + // ── GOLD ────────────────────────────────────────────────────────────── + BadgeDefinition( + id = "workouts_50", title = "Champion", + description = "Complete 50 workouts", tier = BadgeTier.GOLD, + iconResName = "ic_badge_trophy", + unlockCondition = { it.totalWorkouts >= 50 }, + ), + BadgeDefinition( + id = "streak_30", title = "Ironclad", + description = "Achieve a 30-day workout streak", tier = BadgeTier.GOLD, + iconResName = "ic_badge_shield", + unlockCondition = { it.currentStreak >= 30 }, + ), + BadgeDefinition( + id = "workouts_100", title = "Sovereign", + description = "Complete 100 workouts", tier = BadgeTier.GOLD, + iconResName = "ic_badge_crown", + unlockCondition = { it.totalWorkouts >= 100 }, + ), + BadgeDefinition( + id = "volume_milestone", title = "Summit", + description = "Lift 100,000 kg total volume", tier = BadgeTier.GOLD, + iconResName = "ic_badge_mountain", + unlockCondition = { it.totalVolumeKg >= 100_000.0 }, + ), + // ── BLUE ────────────────────────────────────────────────────────────── + BadgeDefinition( + id = "member_365", title = "Eternal", + description = "365 days since your first workout", tier = BadgeTier.BLUE, + iconResName = "ic_badge_infinity", + unlockCondition = { it.daysSinceFirstWorkout >= 365 }, + ), + BadgeDefinition( + id = "all_goal_modes", title = "Multiclass", + description = "Train with all 4 goal modes", tier = BadgeTier.BLUE, + iconResName = "ic_badge_3stars", + unlockCondition = { it.goalModesUsed.size >= 4 }, + ), + BadgeDefinition( + id = "s_rank", title = "Diamond", + description = "Reach S-Rank status", tier = BadgeTier.BLUE, + iconResName = "ic_badge_diamond", + unlockCondition = { it.currentRank == "S" }, + ), + ) + + /** Returns the set of badge IDs that are unlocked for the given [stats]. */ + fun evaluate(stats: AppStats): Set = + all.filter { it.unlockCondition(stats) }.map { it.id }.toSet() +} diff --git a/app/src/main/java/com/ironlog/app/domain/gamification/GamificationSummary.kt b/app/src/main/java/com/ironlog/app/domain/gamification/GamificationSummary.kt new file mode 100644 index 0000000..9eb5b49 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/gamification/GamificationSummary.kt @@ -0,0 +1,143 @@ +package com.ironlog.app.domain.gamification + +import com.ironlog.app.assets.ForgeFoxExpression +import com.ironlog.app.ui.model.HistoryEntry +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.temporal.ChronoUnit + +enum class DailyProofStatus { + SETUP, + ACTIVE_WORKOUT, + PROOF_LOGGED, + TRAIN_TODAY, + AT_RISK, + RECOVER_SMART, +} + +data class DailyProofSummary( + val status: DailyProofStatus, + val headline: String, + val detail: String, + val primaryActionLabel: String, + val primaryRoute: String, + val foxExpressionId: String, +) + +internal fun parseHistoryInstant(value: String): Instant? { + val trimmed = value.trim() + if (trimmed.isBlank()) return null + + return runCatching { Instant.parse(trimmed) }.getOrNull() + ?: runCatching { + LocalDate.parse(trimmed).atStartOfDay(ZoneOffset.UTC).toInstant() + }.getOrNull() + ?: runCatching { + LocalDateTime.parse(trimmed.replace(" ", "T")) + .atZone(ZoneId.systemDefault()) + .toInstant() + }.getOrNull() +} + +internal fun parseHistoryLocalDate(value: String): LocalDate? = + parseHistoryInstant(value)?.atZone(ZoneOffset.UTC)?.toLocalDate() + +fun dailyWorkoutStreakDays( + history: List, + nowEpochMs: Long = System.currentTimeMillis(), +): Int { + if (history.isEmpty()) return 0 + val dates = history.mapNotNull { parseHistoryLocalDate(it.date) }.toSet() + if (dates.isEmpty()) return 0 + + val today = Instant.ofEpochMilli(nowEpochMs).atZone(ZoneId.systemDefault()).toLocalDate() + val yesterday = today.minusDays(1) + if (today !in dates && yesterday !in dates) return 0 + + var streak = 0 + var cursor = if (today in dates) today else yesterday + while (cursor in dates) { + streak++ + cursor = cursor.minusDays(1) + } + return streak +} + +fun buildDailyProofSummary( + history: List, + hasActivePlan: Boolean, + activeWorkoutDayName: String?, + readinessScore: Int?, + nowEpochMs: Long = System.currentTimeMillis(), +): DailyProofSummary { + if (!activeWorkoutDayName.isNullOrBlank()) { + return DailyProofSummary( + status = DailyProofStatus.ACTIVE_WORKOUT, + headline = activeWorkoutDayName, + detail = "Current session is still in progress.", + primaryActionLabel = "Resume workout", + primaryRoute = "ActiveWorkout", + foxExpressionId = ForgeFoxExpression.Determined.id, + ) + } + + if (history.isEmpty() && !hasActivePlan) { + return DailyProofSummary( + status = DailyProofStatus.SETUP, + headline = "Set your proof loop", + detail = "Pick a plan so Home can drive the next session automatically.", + primaryActionLabel = "Choose a program", + primaryRoute = "ProgramPicker", + foxExpressionId = ForgeFoxExpression.Clipboard.id, + ) + } + + val today = Instant.ofEpochMilli(nowEpochMs).atZone(ZoneId.systemDefault()).toLocalDate() + val lastWorkoutDate = history.mapNotNull { parseHistoryLocalDate(it.date) }.maxOrNull() + val daysSinceWorkout = lastWorkoutDate?.let { ChronoUnit.DAYS.between(it, today).toInt() } ?: Int.MAX_VALUE + + if (daysSinceWorkout <= 0) { + return DailyProofSummary( + status = DailyProofStatus.PROOF_LOGGED, + headline = "Proof saved", + detail = "Today's session already counts toward your ledger.", + primaryActionLabel = "Open Iron Ledger", + primaryRoute = "statusWindow", + foxExpressionId = ForgeFoxExpression.Proud.id, + ) + } + + if ((readinessScore ?: 0) >= 78) { + return DailyProofSummary( + status = DailyProofStatus.TRAIN_TODAY, + headline = "Fresh enough to press", + detail = "Recovery is high enough to push the next proof session.", + primaryActionLabel = "Train today", + primaryRoute = "Home", + foxExpressionId = ForgeFoxExpression.Flexing.id, + ) + } + + if (daysSinceWorkout >= 2) { + return DailyProofSummary( + status = DailyProofStatus.AT_RISK, + headline = "Your rhythm is slipping", + detail = "The next workout matters more than another scroll.", + primaryActionLabel = "Train today", + primaryRoute = "Home", + foxExpressionId = ForgeFoxExpression.CheckingWatch.id, + ) + } + + return DailyProofSummary( + status = DailyProofStatus.RECOVER_SMART, + headline = "Recover on purpose", + detail = "Use today's readiness before forcing extra fatigue.", + primaryActionLabel = "Open recovery", + primaryRoute = "RecoveryMap", + foxExpressionId = ForgeFoxExpression.RestBlanket.id, + ) +} diff --git a/app/src/main/java/com/ironlog/app/domain/gamification/IronLedgerEngine.kt b/app/src/main/java/com/ironlog/app/domain/gamification/IronLedgerEngine.kt new file mode 100644 index 0000000..0e4e833 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/gamification/IronLedgerEngine.kt @@ -0,0 +1,422 @@ +package com.ironlog.app.domain.gamification + +import androidx.compose.runtime.Immutable +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.HistoryExercise +import com.ironlog.app.ui.model.HistoryExerciseSet +import java.time.Instant +import java.time.ZoneOffset +import java.time.temporal.ChronoUnit +import java.time.temporal.WeekFields +import java.util.Locale +import kotlin.math.ln +import kotlin.math.max +import kotlin.math.roundToInt + +enum class IronGrade( + val label: String, + val minVerifiedSessions: Int, + val minQualifyingWeeks: Int, + val minTenureDays: Int, +) { + UNCALIBRATED("Uncalibrated", 0, 0, 0), + GRAPHITE("Graphite", 4, 2, 14), + IRON("Iron", 12, 3, 28), + STEEL("Steel", 36, 8, 90), + TITANIUM("Titanium", 80, 20, 180), + OBSIDIAN("Obsidian", 160, 40, 365), + IRIDIUM("Iridium", 300, 90, 730), + AETHER("Aether", 450, 140, 1095), + APEX("Apex", 650, 200, 1460), +} + +@Immutable +data class AthleteCalibration( + val trainingAgeMonths: Int = 0, + val historicalTrainingDaysPerWeek: Int = 3, + val importedHistory: Boolean = false, + val weeklyGoalDays: Int = 4, + val bodyweightKg: Double? = null, + val hasPastTraining: Boolean = false, + val hasGymAccess: Boolean = true, + val baselinePushups: Int = 0, + val baselinePullups: Int = 0, + val baselineBenchKg: Int = 0, + val baselineLatPulldownKg: Int = 0, + val baselineMileRunSeconds: Int = 0, +) + +@Immutable +data class IronLedgerStats( + val strength: Int = 1, + val power: Int = 1, + val hypertrophy: Int = 1, + val endurance: Int = 1, + val agility: Int = 1, + val discipline: Int = 1, + val recovery: Int = 1, +) + +@Immutable +data class IronGradeGate( + val grade: IronGrade, + val label: String, + val current: Int, + val required: Int, + val met: Boolean, +) + +@Immutable +data class IronLedgerEvent( + val sourceId: String, + val kind: String, + val title: String, + val detail: String, + val xp: Int, + val occurredAt: String, + val trust: Double, +) + +@Immutable +data class IronLedgerSnapshot( + val totalXp: Long, + val level: Int, + val xpInLevel: Long, + val xpForNextLevel: Long, + val grade: IronGrade, + val stats: IronLedgerStats, + val integrityScore: Double, + val verifiedSessions: Int, + val qualifyingWeeks: Int, + val tenureDays: Int, + val nextGrade: IronGrade?, + val nextGradeGates: List, + val events: List, +) + +class IronLedgerEngine { + fun xpForLevel(level: Int): Long = + (125.0 * level.toDouble() * level.toDouble()).roundToInt().toLong().coerceAtLeast(125L) + + fun levelFromTotalXp(totalXp: Long): Int { + var level = 1 + var remaining = totalXp + while (level < 100 && remaining >= xpForLevel(level)) { + remaining -= xpForLevel(level) + level++ + } + return level + } + + fun xpInCurrentLevel(totalXp: Long): Long { + val level = levelFromTotalXp(totalXp) + val spent = (1 until level).sumOf { xpForLevel(it) } + return (totalXp - spent).coerceAtLeast(0L) + } + + fun rebuild( + history: List, + weeklyGoal: Int, + calibration: AthleteCalibration, + ): IronLedgerSnapshot { + val sorted = history.sortedBy { it.date } + val qualified = sorted.filter(::isQualifyingWorkout) + val verified = qualified.filterNot { it.imported } + val firstDate = verified.firstOrNull()?.date?.let(::parseInstant) + val lastDate = verified.lastOrNull()?.date?.let(::parseInstant) ?: Instant.now() + val tenureDays = firstDate?.let { ChronoUnit.DAYS.between(it, lastDate).toInt().coerceAtLeast(0) } ?: 0 + val qualifyingWeeks = verified.mapNotNull { weekKey(it.date) }.distinct().size + val integrity = integrityScore(verified, verified) + val events = buildEvents(sorted, qualified, integrity) + val totalXp = events.sumOf { it.xp.toLong() }.coerceAtLeast(0L) + val level = levelFromTotalXp(totalXp) + val stats = computeStats(sorted, qualifyingWeeks, calibration) + val grade = gradeFor( + verifiedSessions = verified.size, + qualifyingWeeks = qualifyingWeeks, + tenureDays = tenureDays, + integrity = integrity, + stats = stats, + calibration = calibration, + ) + val nextGrade = IronGrade.entries.firstOrNull { it.ordinal > grade.ordinal } + val gates = nextGrade?.let { ng -> + buildList { + add(IronGradeGate(ng, "Verified sessions", verified.size, ng.minVerifiedSessions, verified.size >= ng.minVerifiedSessions)) + add(IronGradeGate(ng, "Qualifying weeks", qualifyingWeeks, ng.minQualifyingWeeks, qualifyingWeeks >= ng.minQualifyingWeeks)) + add(IronGradeGate(ng, "Training tenure", tenureDays, ng.minTenureDays, tenureDays >= ng.minTenureDays)) + // Integrity gate only applies for OBSIDIAN and above + if (ng.ordinal >= IronGrade.OBSIDIAN.ordinal) { + val required = if (ng.ordinal >= IronGrade.APEX.ordinal) 95 else 88 + val threshold = required / 100.0 + add(IronGradeGate(ng, "Integrity", (integrity * 100).roundToInt(), required, integrity >= threshold)) + } + } + }.orEmpty() + + return IronLedgerSnapshot( + totalXp = totalXp, + level = level, + xpInLevel = xpInCurrentLevel(totalXp), + xpForNextLevel = xpForLevel(level), + grade = grade, + stats = stats, + integrityScore = integrity, + verifiedSessions = verified.size, + qualifyingWeeks = qualifyingWeeks, + tenureDays = tenureDays, + nextGrade = nextGrade, + nextGradeGates = gates, + events = events.sortedByDescending { it.occurredAt }, + ) + } + + private fun buildEvents( + history: List, + qualified: List, + integrity: Double, + ): List { + val qualifiedIds = qualified.map { it.id }.toSet() + val dailyCounts = qualified.groupingBy { dayKey(it.date) ?: it.date }.eachCount() + val previousBestByExercise = mutableMapOf() + val events = mutableListOf() + history.sortedBy { it.date }.forEach { workout -> + if (workout.id !in qualifiedIds) return@forEach + val dayCount = dailyCounts[dayKey(workout.date) ?: workout.date] ?: 1 + val dailyMultiplier = when { + dayCount <= 1 -> 1.0 + dayCount == 2 -> 0.45 + else -> 0.0 + } + val importMultiplier = if (workout.imported) 0.55 else 1.0 + val trust = (integrity * dailyMultiplier * importMultiplier).coerceIn(0.0, 1.0) + val hardSets = hardSets(workout) + val baseXp = ((40 + hardSets.coerceAtMost(18) * 2) * trust).roundToInt() + if (baseXp > 0) { + events += IronLedgerEvent( + sourceId = workout.id, + kind = "workout", + title = "Training proof logged", + detail = "${workout.name} - $hardSets hard sets", + xp = baseXp, + occurredAt = workout.date, + trust = trust, + ) + } + workout.exercises.forEach { exercise -> + val best = exercise.sets + .filter(::isWorkingSet) + .mapNotNull { set -> estimatedPerformance(exercise, set) } + .maxOrNull() + ?: return@forEach + val key = exercise.exerciseId.ifBlank { exercise.name.lowercase(Locale.ROOT) } + val previous = previousBestByExercise[key] + if (previous != null && best > previous * 1.025 && trust >= 0.75) { + val eventSourceId = "${workout.id}:${exercise.id.ifBlank { key }}" + events += IronLedgerEvent( + sourceId = eventSourceId, + kind = "pr", + title = "Verified PR", + detail = "${exercise.name} improved ${previous.roundToInt()} -> ${best.roundToInt()}", + xp = 35, + occurredAt = workout.date, + trust = trust, + ) + } + previousBestByExercise[key] = max(previous ?: 0.0, best) + } + } + return events + } + + private fun gradeFor( + verifiedSessions: Int, + qualifyingWeeks: Int, + tenureDays: Int, + integrity: Double, + stats: IronLedgerStats, + calibration: AthleteCalibration, + ): IronGrade { + val balance = listOf(stats.strength, stats.hypertrophy, stats.discipline).average() + val verifiedGrade = IronGrade.entries.lastOrNull { grade -> + verifiedSessions >= grade.minVerifiedSessions && + qualifyingWeeks >= grade.minQualifyingWeeks && + tenureDays >= grade.minTenureDays && + (grade.ordinal < IronGrade.OBSIDIAN.ordinal || integrity >= 0.88) && + (grade.ordinal < IronGrade.IRIDIUM.ordinal || balance >= 300.0) && + (grade.ordinal < IronGrade.APEX.ordinal || integrity >= 0.95) + } ?: IronGrade.UNCALIBRATED + val seededGrade = baselineGrade(calibration, stats) + return if (seededGrade.ordinal > verifiedGrade.ordinal) seededGrade else verifiedGrade + } + + private fun computeStats( + history: List, + qualifyingWeeks: Int, + calibration: AthleteCalibration, + ): IronLedgerStats { + val sets = history.flatMap { workout -> workout.exercises.flatMap { ex -> ex.sets.map { ex to it } } } + val working = sets.filter { (_, set) -> isWorkingSet(set) } + val strengthRaw = working + .filter { (ex, set) -> !isCardio(ex) && set.weight > 0.0 && set.reps > 0.0 } + .maxOfOrNull { (_, set) -> epley(set.weight, set.reps) } + ?: 0.0 + val enduranceRaw = history.sumOf { workout -> + workout.exercises.sumOf { ex -> + if (isCardio(ex)) ex.sets.sumOf { it.reps.coerceAtLeast(0.0) } / 60.0 else 0.0 + } + } + history.sumOf { if (it.duration >= 45 * 60) 2.0 else 0.0 } + val hypertrophyRaw = working.count().toDouble() + val powerRaw = working.count { (ex, set) -> + !isCardio(ex) && set.weight > 0.0 && set.reps in 1.0..5.0 + }.toDouble() + val agilityRaw = working.count { (ex, _) -> + val name = ex.name.lowercase(Locale.ROOT) + name.contains("jump") || name.contains("lunge") || name.contains("single") || + name.contains("carry") || name.contains("crawl") || ex.category.orEmpty().contains("conditioning", ignoreCase = true) + }.toDouble() + val disciplineRaw = qualifyingWeeks * 4.0 + working.count { (_, set) -> set.rpe != null || set.rir != null } + val recoveryRaw = qualifyingWeeks * 3.0 + history.count { it.duration in 20 * 60..120 * 60 } + + val verifiedStats = IronLedgerStats( + strength = toStat(strengthRaw, 120.0), + power = toStat(powerRaw, 20.0), + hypertrophy = toStat(hypertrophyRaw, 80.0), + endurance = toStat(enduranceRaw, 20.0), + agility = toStat(agilityRaw, 20.0), + discipline = toStat(disciplineRaw, 50.0), + recovery = toStat(recoveryRaw, 50.0), + ) + val seededStats = baselineStats(calibration) + return IronLedgerStats( + strength = max(verifiedStats.strength, seededStats.strength), + power = max(verifiedStats.power, seededStats.power), + hypertrophy = max(verifiedStats.hypertrophy, seededStats.hypertrophy), + endurance = max(verifiedStats.endurance, seededStats.endurance), + agility = max(verifiedStats.agility, seededStats.agility), + discipline = max(verifiedStats.discipline, seededStats.discipline), + recovery = max(verifiedStats.recovery, seededStats.recovery), + ) + } + + private fun hasMeaningfulBaseline(calibration: AthleteCalibration): Boolean = + calibration.trainingAgeMonths > 0 || + calibration.bodyweightKg != null || + calibration.hasPastTraining || + calibration.baselinePushups > 0 || + calibration.baselinePullups > 0 || + calibration.baselineBenchKg > 0 || + calibration.baselineLatPulldownKg > 0 || + calibration.baselineMileRunSeconds > 0 + + private fun estimatedLifetimeSessions(calibration: AthleteCalibration): Int = + (calibration.trainingAgeMonths * 4.345 * max(1, calibration.historicalTrainingDaysPerWeek)).roundToInt().coerceAtLeast(0) + + private fun baselineStats(calibration: AthleteCalibration): IronLedgerStats { + if (!hasMeaningfulBaseline(calibration)) return IronLedgerStats() + val sessions = estimatedLifetimeSessions(calibration).toDouble() + val bodyweight = calibration.bodyweightKg ?: 0.0 + val push = calibration.baselinePushups.toDouble() + val pull = calibration.baselinePullups.toDouble() + val bench = calibration.baselineBenchKg.toDouble() + val lat = calibration.baselineLatPulldownKg.toDouble() + val mile = calibration.baselineMileRunSeconds.takeIf { it > 0 }?.toDouble() + val paceFactor = mile?.let { (900.0 / it).coerceIn(0.0, 2.0) } ?: 0.0 + + val strengthRaw = bench * 2.4 + lat * 1.5 + pull * 6.0 + push * 1.2 + bodyweight * 0.45 + sessions * 0.30 + val powerRaw = bench * 1.6 + pull * 3.5 + push * 0.8 + sessions * 0.12 + val hypertrophyRaw = bench * 1.4 + lat * 1.1 + push * 1.6 + pull * 2.2 + sessions * 0.22 + val enduranceRaw = push * 0.9 + sessions * 0.08 + paceFactor * 30.0 + calibration.weeklyGoalDays * 2.5 + val agilityRaw = pull * 1.0 + paceFactor * 55.0 + sessions * 0.05 + val disciplineRaw = sessions * 0.16 + calibration.weeklyGoalDays * 8.0 + if (calibration.hasPastTraining) 18.0 else 0.0 + val recoveryRaw = sessions * 0.10 + calibration.weeklyGoalDays * 6.0 + if (calibration.hasGymAccess) 8.0 else 4.0 + + return IronLedgerStats( + strength = toStat(strengthRaw, 45.0), + power = toStat(powerRaw, 28.0), + hypertrophy = toStat(hypertrophyRaw, 55.0), + endurance = toStat(enduranceRaw, 18.0), + agility = toStat(agilityRaw, 16.0), + discipline = toStat(disciplineRaw, 22.0), + recovery = toStat(recoveryRaw, 24.0), + ) + } + + private fun baselineGrade( + calibration: AthleteCalibration, + stats: IronLedgerStats, + ): IronGrade { + if (!hasMeaningfulBaseline(calibration)) return IronGrade.UNCALIBRATED + val sessions = estimatedLifetimeSessions(calibration) + val months = calibration.trainingAgeMonths + val score = listOf( + stats.strength, + stats.power, + stats.hypertrophy, + stats.endurance, + stats.agility, + stats.discipline, + stats.recovery, + ).average() + return when { + score >= 150.0 && sessions >= 180 && months >= 16 -> IronGrade.TITANIUM + score >= 110.0 && sessions >= 90 && months >= 10 -> IronGrade.STEEL + score >= 80.0 && sessions >= 28 && months >= 6 -> IronGrade.IRON + score >= 45.0 && sessions >= 8 && months >= 2 -> IronGrade.GRAPHITE + else -> IronGrade.UNCALIBRATED + } + } + + private fun integrityScore( + history: List, + qualified: List, + ): Double { + if (history.isEmpty()) return 1.0 + val dailyMax = qualified.groupingBy { dayKey(it.date) ?: it.date }.eachCount().values.maxOrNull() ?: 0 + val veryShort = history.count { it.duration in 1 until 10 * 60 } + var score = 1.0 + if (dailyMax > 2) score -= (dailyMax - 2) * 0.12 + score -= veryShort * 0.03 + return score.coerceIn(0.35, 1.0) + } + + private fun isQualifyingWorkout(workout: HistoryEntry): Boolean { + val hardSets = hardSets(workout) + val cardioMinutes = workout.exercises + .filter(::isCardio) + .sumOf { ex -> ex.sets.sumOf { it.reps.coerceAtLeast(0.0) } / 60.0 } + if (hardSets == 0 && cardioMinutes <= 0.0) return false + return hardSets >= 8 || (hardSets >= 3 && workout.duration >= 20 * 60) || cardioMinutes >= 10.0 + } + + private fun hardSets(workout: HistoryEntry): Int = + workout.exercises.sumOf { ex -> ex.sets.count(::isWorkingSet) } + + private fun isWorkingSet(set: HistoryExerciseSet): Boolean = + set.type.lowercase(Locale.ROOT) != "warmup" && (set.weight > 0.0 || set.reps > 0.0) + + private fun isCardio(exercise: HistoryExercise): Boolean { + val haystack = "${exercise.name} ${exercise.category.orEmpty()} ${exercise.primaryMuscle.orEmpty()}".lowercase(Locale.ROOT) + return listOf("cardio", "run", "treadmill", "bike", "cycle", "rower", "swim", "elliptical", "conditioning") + .any { it in haystack } + } + + private fun estimatedPerformance(exercise: HistoryExercise, set: HistoryExerciseSet): Double = + if (isCardio(exercise) || set.weight <= 0.0) set.reps else epley(set.weight, set.reps) + + private fun epley(weight: Double, reps: Double): Double = weight * (1.0 + reps / 30.0) + + private fun toStat(value: Double, scale: Double): Int = + (1 + ln(1.0 + value / scale) * 180.0).roundToInt().coerceIn(1, 999) + + private fun parseInstant(value: String) = parseHistoryInstant(value) + + private fun dayKey(value: String): String? = + parseInstant(value)?.atZone(ZoneOffset.UTC)?.toLocalDate()?.toString() + + private fun weekKey(value: String): String? { + val date = parseInstant(value)?.atZone(ZoneOffset.UTC)?.toLocalDate() ?: return null + val wf = WeekFields.ISO + return "${date.get(wf.weekBasedYear())}-W${date.get(wf.weekOfWeekBasedYear())}" + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/gamification/StatEngine.kt b/app/src/main/java/com/ironlog/app/domain/gamification/StatEngine.kt new file mode 100644 index 0000000..1aa5823 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/gamification/StatEngine.kt @@ -0,0 +1,81 @@ +// app/src/main/java/com/ironlog/app/domain/gamification/StatEngine.kt +package com.ironlog.app.domain.gamification + +import com.ironlog.app.ui.model.HistoryEntry +import kotlinx.serialization.Serializable +import kotlin.math.ln +import kotlin.math.roundToInt + +@Serializable +data class RpgStats( + val str: Int = 1, // Strength - best estimated 1RM across all exercises + val vit: Int = 1, // Vitality - streak + total sessions + val end: Int = 1, // Endurance - average workout duration + val agi: Int = 1, // Agility - exercise variety per session + val wis: Int = 1, // Wisdom - RPE/effort tracking frequency (future; now = 1) + val luk: Int = 1, // Luck - rare events such as PRs, streaks, and high-effort proof +) + +class StatEngine { + + /** Epley formula: 1RM ~= weight * (1 + reps/30). */ + private fun epley1rm(weight: Double, reps: Double): Double = + weight * (1.0 + reps / 30.0) + + /** + * Map a raw metric to a 1-999 stat value using logarithmic scaling. + * [value] is the raw metric; [scale] controls how quickly stat grows. + */ + private fun toStat(value: Double, scale: Double): Int = + (1 + (ln(1.0 + value / scale) * 200.0)).roundToInt().coerceIn(1, 999) + + fun compute( + history: List, + streak: Int, + totalSessions: Int, + ): RpgStats { + if (history.isEmpty()) return RpgStats() + + // STR - best estimated 1RM across all sets in all history. + val bestOrm: Double = history.flatMap { it.exercises } + .flatMap { it.sets } + .filter { it.type != "warmup" && it.reps > 0 && it.weight > 0 } + .maxOfOrNull { epley1rm(it.weight, it.reps) } ?: 0.0 + + // END - average workout duration in minutes. + val avgDurationMin = history.map { it.duration / 60.0 }.average() + + // AGI - average distinct exercises per session. + val avgExercises = history.map { it.exercises.size.toDouble() }.average() + + // VIT - blend of streak weeks and total sessions. + val vitRaw = streak * 5.0 + totalSessions.toDouble() + + // WIS - fraction of sets that have RPE logged, scaled to encourage consistent tracking. + val totalSets = history.sumOf { it.exercises.sumOf { ex -> ex.sets.size } }.toDouble() + val rpeTrackedSets = history.sumOf { entry -> + entry.exercises.sumOf { ex -> ex.sets.count { it.rpe != null } } + }.toDouble() + val wisRaw = if (totalSets > 0) (rpeTrackedSets / totalSets) * 100.0 else 0.0 + val wis = toStat(wisRaw, scale = 20.0) + + // LUK - high-effort proof events, approximated as high-RPE sets for now. + val luk = toStat( + value = history.sumOf { entry -> + entry.exercises.sumOf { ex -> + ex.sets.count { it.rpe != null && (it.rpe ?: 0.0) >= 9.0 }.toDouble() + } + }, + scale = 20.0, + ) + + return RpgStats( + str = toStat(bestOrm, scale = 100.0), + vit = toStat(vitRaw, scale = 50.0), + end = toStat(avgDurationMin, scale = 30.0), + agi = toStat(avgExercises, scale = 5.0), + wis = wis, + luk = luk, + ) + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/gamification/StreakEngine.kt b/app/src/main/java/com/ironlog/app/domain/gamification/StreakEngine.kt new file mode 100644 index 0000000..2c3f023 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/gamification/StreakEngine.kt @@ -0,0 +1,80 @@ +// app/src/main/java/com/ironlog/app/domain/gamification/StreakEngine.kt +package com.ironlog.app.domain.gamification + +import com.ironlog.app.ui.model.HistoryEntry +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.temporal.TemporalAdjusters +import java.time.temporal.WeekFields + +class StreakEngine { + + private val isoWeek = WeekFields.ISO + + /** + * ISO week key, e.g. "2026-W21". + */ + private fun LocalDate.isoWeekKey(): String { + val week = get(isoWeek.weekOfWeekBasedYear()) + val year = get(isoWeek.weekBasedYear()) + return "$year-W${week.toString().padStart(2, '0')}" + } + + /** + * Computes the current streak in qualifying weeks. + * + * A week qualifies if: + * - sessions in that week >= [weeklyGoal], OR + * - sessions == weeklyGoal - 1 AND [recoveryCircuitCompletions][weekKey] >= 1 + * + * The streak counts backward from the most recent qualifying week. + * A missing week (no workouts at all, not even goal-1+recovery circuit) breaks the streak. + * + * @param history All workout history entries. + * @param weeklyGoal Sessions required per week (from IronLogSettings.weeklyGoalDays). + * @param recoveryCircuitCompletions Map of ISO-week-key -> number of recovery circuits completed. + */ + fun computeStreakWeeks( + history: List, + weeklyGoal: Int, + recoveryCircuitCompletions: Map, + asOfDate: LocalDate = LocalDate.now(), + ): Int { + if (history.isEmpty()) return 0 + + // Group workouts by ISO week key + val fmt = DateTimeFormatter.ISO_LOCAL_DATE + val sessionsByWeek: Map = history + .mapNotNull { entry -> + runCatching { LocalDate.parse(entry.date.take(10), fmt) }.getOrNull() + ?.isoWeekKey() + } + .groupingBy { it } + .eachCount() + + if (sessionsByWeek.isEmpty()) return 0 + + // Start from the most recent week that has any sessions + val goal = weeklyGoal.coerceAtLeast(1) + val currentMonday = asOfDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + fun qualifies(monday: LocalDate): Boolean { + val weekKey = monday.isoWeekKey() + val sessions = sessionsByWeek[weekKey] ?: 0 + val recoveryCircuits = recoveryCircuitCompletions[weekKey] ?: 0 + return sessions >= goal || (sessions == goal - 1 && recoveryCircuits >= 1) + } + + var checkMonday = if (qualifies(currentMonday)) currentMonday else currentMonday.minusWeeks(1) + if (!qualifies(checkMonday)) return 0 + + // Walk backward one ISO week at a time — gaps in sessionsByWeek break the streak + var streak = 0 + while (qualifies(checkMonday)) { + streak++ + checkMonday = checkMonday.minusWeeks(1) + } + + return streak + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/gamification/XpEngine.kt b/app/src/main/java/com/ironlog/app/domain/gamification/XpEngine.kt new file mode 100644 index 0000000..7c3f471 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/gamification/XpEngine.kt @@ -0,0 +1,51 @@ +package com.ironlog.app.domain.gamification + +import kotlin.math.roundToInt + +enum class XpAction { + RECOVERY_CIRCUIT, +} + +class XpEngine { + + /** + * XP required to reach [level] (1-indexed). + * Curve: 125.0 * level^2, coerced to at least 125L. + */ + fun xpForLevel(level: Int): Long = + (125.0 * level.toDouble() * level.toDouble()).roundToInt().toLong().coerceAtLeast(125L) + + /** + * Legacy grade bridge retained for old cached profiles. + * Canonical Iron Ledger grades are rebuilt by [IronLedgerEngine]. + */ + fun rankForLevel(level: Int): String = when { + level >= 91 -> "Apex" + level >= 71 -> "S" + level >= 51 -> "A" + level >= 36 -> "B" + level >= 21 -> "C" + level >= 11 -> "D" + else -> "E" + } + + fun xpForAction(action: XpAction): Int = when (action) { + XpAction.RECOVERY_CIRCUIT -> 25 + } + + fun levelFromTotalXp(totalXp: Long): Int { + var level = 1 + var remaining = totalXp + while (level < 100 && remaining >= xpForLevel(level)) { + remaining -= xpForLevel(level) + level++ + } + return level + } + + fun xpInCurrentLevel(totalXp: Long): Long { + val level = levelFromTotalXp(totalXp) + val spent = (1 until level).sumOf { xpForLevel(it) } + return (totalXp - spent).coerceAtLeast(0L) + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/intelligence/CloudAiEngine.kt b/app/src/main/java/com/ironlog/app/domain/intelligence/CloudAiEngine.kt new file mode 100644 index 0000000..5fe15db --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/intelligence/CloudAiEngine.kt @@ -0,0 +1,421 @@ +package com.ironlog.app.domain.intelligence + +import com.ironlog.app.ui.model.HistoryEntry +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.android.Android +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import timber.log.Timber + +/** + * HTTP engine for BYOK Cloud AI. + * Supports OpenAI-compatible providers (apiFormat = "openai") and Anthropic Claude (apiFormat = "anthropic"). + * All suspend functions run natively via Ktor coroutines and never throw — they return safe fallback strings. + */ +object CloudAiEngine { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + private val client = HttpClient(Android) { + install(ContentNegotiation) { json(json) } + expectSuccess = true // throw ResponseException on non-2xx so callers get a status-code error + engine { + connectTimeout = 30_000 + socketTimeout = 60_000 + } + } + + // ── Serializable request shapes ─────────────────────────────────────────── + + @Serializable + private data class ChatMessage(val role: String, val content: String) + + @Serializable + private data class OpenAiRequest( + val model: String, + val messages: List, + val max_tokens: Int = 1024, + val temperature: Double = 0.7, + val response_format: ResponseFormat? = null, + ) + + @Serializable + private data class ResponseFormat(val type: String) + + @Serializable + private data class AnthropicRequest( + val model: String, + val messages: List, + val max_tokens: Int = 1024, + val temperature: Double = 0.7, + val system: String? = null, + ) + + // ── Model listing ───────────────────────────────────────────────────────── + + /** + * Lists available models from the provider. + * Both OpenAI and Anthropic return a "data" array with "id" fields at their /models endpoints. + * Returns Result.failure on network error or non-2xx response. + */ + suspend fun fetchModels( + baseUrl: String, + apiKey: String, + apiFormat: String, + ): Result> = runCatching { + val url = if (apiFormat == "anthropic") + "https://api.anthropic.com/v1/models" + else + "${baseUrl.trimEnd('/')}/models" + + val body: JsonObject = client.get(url) { + if (apiFormat == "anthropic") { + header("x-api-key", apiKey) + header("anthropic-version", "2023-06-01") + } else { + header("Authorization", "Bearer $apiKey") + } + }.body() + + val arr: JsonArray = body["data"]?.jsonArray ?: JsonArray(emptyList()) + // removePrefix("models/") strips the Gemini-style "models/..." prefix; no-op for OpenAI/Anthropic IDs + arr.mapNotNull { it.jsonObject["id"]?.jsonPrimitive?.content?.removePrefix("models/") } + }.onFailure { Timber.w(it, "fetchModels failed") } + + // ── Connection verification ─────────────────────────────────────────────── + + /** + * Fires a minimal request (max_tokens=1) to verify the key and endpoint work. + * Returns Result.success(Unit) on any 2xx, Result.failure otherwise. + */ + suspend fun verify( + baseUrl: String, + apiKey: String, + modelName: String, + apiFormat: String, + ): Result = runCatching { + if (apiFormat == "anthropic") { + client.post("https://api.anthropic.com/v1/messages") { + header("x-api-key", apiKey) + header("anthropic-version", "2023-06-01") + contentType(ContentType.Application.Json) + setBody(AnthropicRequest( + model = modelName, + messages = listOf(ChatMessage("user", "Reply OK.")), + max_tokens = 8, + temperature = 0.0, + )) + } + } else { + client.post("${baseUrl.trimEnd('/')}/chat/completions") { + header("Authorization", "Bearer $apiKey") + contentType(ContentType.Application.Json) + setBody(OpenAiRequest( + model = modelName, + messages = listOf(ChatMessage("user", "Reply OK.")), + max_tokens = 8, + temperature = 0.0, + )) + } + } + Unit + }.onFailure { Timber.w(it, "verify failed") } + + // ── Four insight query functions ────────────────────────────────────────── + + suspend fun askRecovery( + baseUrl: String, + apiKey: String, + modelName: String, + apiFormat: String, + readiness: Map, + ): String { + if (apiKey.isBlank() || baseUrl.isBlank()) return "Configure your Cloud AI key in Settings." + val readinessText = readiness.entries + .sortedBy { it.key } + .joinToString(", ") { (k, v) -> "$k ${(v * 100).toInt()}%" } + val prompt = """You are a concise personal trainer AI inside the IronLog workout app. +Based on these muscle group recovery scores, give ONE actionable recommendation for today in 2 sentences max. +Recovery: $readinessText +Rules: under 60 words, plain text only, no markdown, no bullet points.""" + return runCatching { chat(baseUrl, apiKey, modelName, apiFormat, prompt) } + .getOrElse { Timber.w(it, "askRecovery failed"); "" } + } + + suspend fun askSplitSuggestion( + baseUrl: String, + apiKey: String, + modelName: String, + apiFormat: String, + history: List, + weeklyGoalDays: Int, + goalMode: String, + ): String { + if (apiKey.isBlank() || baseUrl.isBlank()) return "Configure your Cloud AI key in Settings." + val goal = when (goalMode) { + "strength" -> "strength and powerlifting" + "general_fitness" -> "general fitness and conditioning" + else -> "muscle hypertrophy" + } + val recentMuscles = history.take(20) + .flatMap { it.exercises } + .mapNotNull { it.primaryMuscle?.takeIf { m -> m.isNotBlank() } ?: it.name.takeIf { n -> n.isNotBlank() } } + .distinct().take(12).joinToString(", ").ifBlank { "various muscle groups" } + val prompt = """You are a concise personal trainer AI inside the IronLog workout app. +Suggest a $weeklyGoalDays-day weekly split optimised for $goal in 3–4 sentences. +The athlete has recently trained: $recentMuscles. +Be specific (Push/Pull/Legs, Upper/Lower, etc.). Under 80 words. Plain text only, no markdown.""" + return runCatching { chat(baseUrl, apiKey, modelName, apiFormat, prompt) } + .getOrElse { Timber.w(it, "askSplitSuggestion failed"); "" } + } + + suspend fun askDayEvaluation( + baseUrl: String, + apiKey: String, + modelName: String, + apiFormat: String, + dayName: String, + exerciseNames: List, + goalMode: String, + ): String { + if (apiKey.isBlank() || baseUrl.isBlank()) return "Configure your Cloud AI key in Settings." + val goal = when (goalMode) { + "strength" -> "strength" + "general_fitness" -> "general fitness" + else -> "hypertrophy" + } + val exList = exerciseNames.take(8).joinToString(", ").ifBlank { "no exercises listed" } + val prompt = """You are a concise personal trainer AI inside the IronLog workout app. +Evaluate this "$dayName" session for $goal: $exList. +Note any imbalances or missing movement patterns in 2–3 sentences. Under 70 words. Plain text only.""" + return runCatching { chat(baseUrl, apiKey, modelName, apiFormat, prompt) } + .getOrElse { Timber.w(it, "askDayEvaluation failed"); "" } + } + + suspend fun askProgressionExplanation( + baseUrl: String, + apiKey: String, + modelName: String, + apiFormat: String, + exerciseName: String, + recentWeightKg: Double, + recentReps: Int, + trend: String, + ): String { + if (apiKey.isBlank() || baseUrl.isBlank()) return "Configure your Cloud AI key in Settings." + val prompt = """You are a concise personal trainer AI inside the IronLog workout app. +$exerciseName: working weight ${recentWeightKg}kg × $recentReps reps, progress trend is $trend. +In 1–2 sentences explain the recommended next progression step. Under 50 words. Plain text only.""" + return runCatching { chat(baseUrl, apiKey, modelName, apiFormat, prompt) } + .getOrElse { Timber.w(it, "askProgressionExplanation failed"); "" } + } + + suspend fun generatePlanJson( + baseUrl: String, + apiKey: String, + modelName: String, + apiFormat: String, + daysPerWeek: Int, + goalMode: String, + equipment: List, + sessionDurationMin: Int, + cardioEverySession: Boolean, + exerciseCatalogMarkdown: String, + ): String { + if (apiKey.isBlank() || baseUrl.isBlank()) return "" + val equipmentText = equipment.joinToString(", ").ifBlank { "Barbell, Dumbbell, Bodyweight" } + val cardioNote = if (cardioEverySession) "Include a short low-intensity cardio exercise only if the user explicitly requested cardio; do not mark it as a warmup." else "" + + val systemPrompt = "You are a JSON API. Output ONLY a single raw JSON object. " + + "Never write any text, explanation, markdown, or code fences before or after the JSON." + + val userPrompt = """Create a $daysPerWeek-day weekly strength training plan. +Goal: $goalMode +Equipment: $equipmentText +Session length: ~$sessionDurationMin minutes +$cardioNote + +Output a single JSON object matching this schema exactly — no deviations, no extra keys at the root: +{ + "type": "ironlog_plan", + "version": 1, + "plan": { + "name": "", + "days": [ + { + "name": "", + "color": "", + "exercises": [ + { + "exerciseName": "", + "primaryMuscle": "", + "secondaryMuscles": [""], + "equipment": "", + "category": "", + "trackingType": "", + "movementPattern": "", + "difficulty": "", + "isBodyweight": , + "sets": , + "reps": "", + "restSeconds": , + "isWarmup": false, + "supersetGroup": null, + "notes": "" + } + ] + } + ] + } +} + +Strict rules: +1. Root must have "type": "ironlog_plan" and "version": 1. +2. "exerciseName" key only — never "name" for exercises. +3. 4-12 exercises per day depending on session length and goal. Do not add warmup exercises or warmup sets. Every exercise must use "isWarmup": false. +4. sets = integer, reps = string ("8-12" or "12"), restSeconds = integer (60-180). +5. Day colors: Push=#FF4500, Pull=#0080FF, Legs=#00C170, Upper=#A020F0, Full Body=#FF8C00, other=#888888. +6. Use common exercise names: "Barbell Bench Press", "Pull-Up", "Barbell Squat", "Romanian Deadlift", etc. +7. If you invent an exercise or use a name that may not exist in Ironlog, include complete metadata: primaryMuscle, secondaryMuscles, equipment, category, trackingType, movementPattern, difficulty, and isBodyweight. This lets Ironlog add it to the local exercise library automatically. +8. Output ONLY the JSON object. Nothing before it. Nothing after it. +9. Write all $daysPerWeek days completely — do not truncate or summarise.""" + + val promptWithCatalog = """ +$userPrompt + +Exercise catalog (compact markdown, source of truth for naming + metadata): +$exerciseCatalogMarkdown +""".trimIndent() + + return runCatching { + chatWithSystem(baseUrl, apiKey, modelName, apiFormat, systemPrompt, promptWithCatalog, maxTokens = 8192, temperature = 0.1, jsonMode = true) + }.getOrElse { Timber.e(it, "generatePlanJson failed"); "" } + } + + suspend fun askStatsSummary( + baseUrl: String, + apiKey: String, + modelName: String, + apiFormat: String, + totalSessions: Int, + streak: Int, + totalSets: Int, + avgDurationMin: Int, + topExercise: String, + weightUnit: String, + ): String { + if (apiKey.isBlank() || baseUrl.isBlank()) return "" + val prompt = """You are a concise personal trainer AI inside the IronLog workout app. +Summarise this athlete's training stats in 1-2 upbeat sentences. Mention highlights and one actionable tip. +Stats: $totalSessions sessions total, $streak-day streak, $totalSets total sets, avg session ${avgDurationMin}min, top exercise: $topExercise, weight unit: $weightUnit. +Under 60 words. Plain text only. No markdown.""" + return runCatching { chat(baseUrl, apiKey, modelName, apiFormat, prompt) } + .getOrElse { Timber.w(it, "askStatsSummary failed"); "" } + } + + // ── Private HTTP helpers ────────────────────────────────────────────────── + + /** Dispatches to openai or anthropic format. Throws on any error — callers wrap in runCatching. */ + private suspend fun chat( + baseUrl: String, + apiKey: String, + modelName: String, + apiFormat: String, + prompt: String, + maxTokens: Int = 1024, + temperature: Double = 0.7, + ): String = chatWithSystem(baseUrl, apiKey, modelName, apiFormat, null, prompt, maxTokens, temperature) + + /** + * Like [chat] but accepts an optional system prompt and forwards it via the appropriate + * API mechanism (system field for Anthropic, system role message for OpenAI). + * @param jsonMode Pass `true` to enable `response_format: json_object` on OpenAI-compatible + * endpoints. Must be set explicitly — there is no auto-detection based on systemPrompt. + * Only [generatePlanJson] should pass `true` here. + */ + private suspend fun chatWithSystem( + baseUrl: String, + apiKey: String, + modelName: String, + apiFormat: String, + systemPrompt: String?, + userPrompt: String, + maxTokens: Int = 1024, + temperature: Double = 0.7, + jsonMode: Boolean = false, + ): String = if (apiFormat == "anthropic") + chatAnthropic(apiKey, modelName, systemPrompt, userPrompt, maxTokens, temperature) + else + chatOpenAi(baseUrl, apiKey, modelName, systemPrompt, userPrompt, maxTokens, temperature, jsonMode) + + private suspend fun chatOpenAi( + baseUrl: String, + apiKey: String, + modelName: String, + systemPrompt: String?, + userPrompt: String, + maxTokens: Int, + temperature: Double, + jsonMode: Boolean, + ): String { + val messages = buildList { + if (!systemPrompt.isNullOrBlank()) add(ChatMessage("system", systemPrompt)) + add(ChatMessage("user", userPrompt)) + } + val response: JsonObject = client.post("${baseUrl.trimEnd('/')}/chat/completions") { + header("Authorization", "Bearer $apiKey") + contentType(ContentType.Application.Json) + setBody(OpenAiRequest( + model = modelName, + messages = messages, + max_tokens = maxTokens, + temperature = temperature, + response_format = if (jsonMode) ResponseFormat("json_object") else null, + )) + }.body() + return response["choices"]?.jsonArray + ?.firstOrNull()?.jsonObject?.get("message")?.jsonObject?.get("content")?.jsonPrimitive?.content + ?.trim() + ?: error("Empty OpenAI response") + } + + private suspend fun chatAnthropic( + apiKey: String, + modelName: String, + systemPrompt: String?, + userPrompt: String, + maxTokens: Int, + temperature: Double, + ): String { + val response: JsonObject = client.post("https://api.anthropic.com/v1/messages") { + header("x-api-key", apiKey) + header("anthropic-version", "2023-06-01") + contentType(ContentType.Application.Json) + setBody(AnthropicRequest( + model = modelName, + messages = listOf(ChatMessage("user", userPrompt)), + max_tokens = maxTokens, + temperature = temperature, + system = systemPrompt?.takeIf { it.isNotBlank() }, + )) + }.body() + return response["content"]?.jsonArray + ?.firstOrNull()?.jsonObject?.get("text")?.jsonPrimitive?.content + ?.trim() + ?: error("Empty Anthropic response") + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/intelligence/CloudAiKeyStore.kt b/app/src/main/java/com/ironlog/app/domain/intelligence/CloudAiKeyStore.kt new file mode 100644 index 0000000..d0ca1fd --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/intelligence/CloudAiKeyStore.kt @@ -0,0 +1,52 @@ +package com.ironlog.app.domain.intelligence + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +/** + * Per-provider encrypted API key storage. + * Each provider (openai, claude, gemini, …) gets its own slot. + * Keys are NEVER stored in IronLogSettings or ObjectBox. + * Never log or print values returned by [load]. + */ +object CloudAiKeyStore { + + private const val PREFS_FILE = "cloud_ai_key_store" + // Legacy single-key name kept for migration reads. + private const val KEY_LEGACY = "api_key" + + private fun prefs(context: Context) = EncryptedSharedPreferences.create( + context, + PREFS_FILE, + MasterKey.Builder(context.applicationContext) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + private fun keyFor(provider: String) = "api_key_${provider.ifBlank { "custom" }}" + + /** Stores the API key for [provider] encrypted on-device. */ + fun save(context: Context, provider: String, key: String) { + runCatching { prefs(context).edit().putString(keyFor(provider), key).apply() } + } + + /** + * Returns the stored API key for [provider], or "" if not set. + * Falls back to the legacy single-key slot so existing users keep their key after upgrade. + */ + fun load(context: Context, provider: String): String { + val p = runCatching { prefs(context) }.getOrNull() ?: return "" + val providerKey = runCatching { p.getString(keyFor(provider), "") ?: "" }.getOrDefault("") + if (providerKey.isNotBlank()) return providerKey + // Migration: return legacy key (set when there was only one slot) + return runCatching { p.getString(KEY_LEGACY, "") ?: "" }.getOrDefault("") + } + + /** Deletes the stored API key for [provider]. */ + fun clear(context: Context, provider: String) { + runCatching { prefs(context).edit().remove(keyFor(provider)).apply() } + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/intelligence/GeminiNanoEngine.kt b/app/src/main/java/com/ironlog/app/domain/intelligence/GeminiNanoEngine.kt new file mode 100644 index 0000000..4d033ca --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/intelligence/GeminiNanoEngine.kt @@ -0,0 +1,190 @@ +package com.ironlog.app.domain.intelligence + +import android.content.Context +import com.google.ai.edge.aicore.DownloadConfig +import com.google.ai.edge.aicore.GenerativeAIException +import com.google.ai.edge.aicore.GenerativeModel +import com.google.ai.edge.aicore.generationConfig +import com.ironlog.app.ui.model.HistoryEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +// ── Availability enum exposed to UI ────────────────────────────────────────── + +enum class NanoAvailability { + /** Device supports Gemini Nano and the model is ready. */ + SUPPORTED, + /** Model must be downloaded first (may take several minutes on Wi-Fi). */ + NEEDS_DOWNLOAD, + /** + * AICore is present but the Gemini Nano LLM feature (ID ≥ 10000) has not been + * deployed to this device yet. This is resolved via a Google Play system update, + * not by the app itself. + */ + NEEDS_SYSTEM_UPDATE, + /** This device/OS version does not support on-device Gemini Nano. */ + UNSUPPORTED, +} + +// ── Engine singleton ────────────────────────────────────────────────────────── + +object GeminiNanoEngine { + + // Lazy-init so we don't pay model-init cost unless the user enables APEX ENGINE. + @Volatile private var _model: GenerativeModel? = null + + // Cached availability result — avoids repeated IPC probes from Settings + HomeScreen. + @Volatile private var _cachedAvailability: NanoAvailability? = null + + /** Last exception detail from checkAvailability — exposed for diagnostic display in UI. */ + @Volatile var lastAvailabilityError: String = "" + private set + + private fun model(context: Context): GenerativeModel = + _model ?: synchronized(this) { + // Double-checked locking — avoids race where two coroutines both see null. + _model ?: run { + val config = generationConfig { + this.context = context.applicationContext + temperature = 0.7f + topK = 40 + maxOutputTokens = 512 + } + GenerativeModel(generationConfig = config, downloadConfig = DownloadConfig()) + .also { _model = it } + } + } + + // ── Availability ────────────────────────────────────────────────────────── + + /** + * AICore ErrorCode constants (from GenerativeAIException.ErrorCode, which is an IntDef annotation): + * NOT_AVAILABLE = 8 → model not yet downloaded, device IS supported + * BINDING_FAILURE = 601 → AICore service not present on this device + * SERVICE_DISCONNECTED = 602 + * BINDING_DIED = 603 + * NEEDS_SYSTEM_UPDATE = 604 → OS too old / AICore not installed + * NULL_BINDING = 605 → AICore service returned null + * + * Only cache SUPPORTED — negative results are not cached so the user can retry + * (e.g. after enabling AICore in device settings or after a system update). + */ + suspend fun checkAvailability(context: Context): NanoAvailability { + _cachedAvailability?.let { return it } + return withContext(Dispatchers.IO) { + try { + model(context).prepareInferenceEngine() + lastAvailabilityError = "" + NanoAvailability.SUPPORTED.also { _cachedAvailability = it } + } catch (e: GenerativeAIException) { + lastAvailabilityError = "${e::class.simpleName}(code=${e.errorCode}): ${e.message}" + val msg = e.message.orEmpty() + when { + // AICore service can't be bound → device doesn't ship AICore at all + e.errorCode in listOf(601, 602, 603, 604, 605) -> NanoAvailability.UNSUPPORTED + // "Required LLM feature not found" → AICore present but Google hasn't + // pushed the Gemini Nano feature package to this device yet. + // User needs a Google Play system update, not an in-app download. + msg.contains("LLM feature not found", ignoreCase = true) || + msg.contains("NOT_AVAILABLE", ignoreCase = true) -> NanoAvailability.NEEDS_SYSTEM_UPDATE + // Feature registered but not yet downloaded + msg.contains("downloading", ignoreCase = true) -> NanoAvailability.NEEDS_DOWNLOAD + else -> NanoAvailability.NEEDS_SYSTEM_UPDATE + } + } catch (e: Exception) { + lastAvailabilityError = "${e::class.simpleName}: ${e.message}" + NanoAvailability.NEEDS_SYSTEM_UPDATE + } + } + } + + suspend fun downloadModel(context: Context): Result = withContext(Dispatchers.IO) { + runCatching { + model(context).prepareInferenceEngine() + _cachedAvailability = NanoAvailability.SUPPORTED + } + } + + // ── Query functions ─────────────────────────────────────────────────────── + + suspend fun askRecovery( + context: Context, + readiness: Map, + ): String = withContext(Dispatchers.IO) { + val readinessText = readiness.entries + .sortedBy { it.key } + .joinToString(", ") { (k, v) -> "$k ${(v * 100).toInt()}%" } + + val prompt = """You are a concise personal trainer AI inside the IronLog workout app. +Based on these muscle group recovery scores, give ONE actionable recommendation for today in 2 sentences max. +Recovery: $readinessText +Rules: under 60 words, plain text only, no markdown, no bullet points.""" + + runCatching { model(context).generateContent(prompt).text?.trim() } + .getOrNull() ?: "Train the most recovered groups today and keep volume moderate." + } + + suspend fun askSplitSuggestion( + context: Context, + history: List, + weeklyGoalDays: Int, + goalMode: String, + ): String = withContext(Dispatchers.IO) { + val goal = when (goalMode) { + "strength" -> "strength and powerlifting" + "general_fitness" -> "general fitness and conditioning" + else -> "muscle hypertrophy" + } + val recentMuscles = history.take(20) + .flatMap { it.exercises } + .mapNotNull { it.primaryMuscle?.takeIf { m -> m.isNotBlank() } ?: it.name.takeIf { n -> n.isNotBlank() } } + .distinct() + .take(12) + .joinToString(", ") + .ifBlank { "various muscle groups" } + + val prompt = """You are a concise personal trainer AI inside the IronLog workout app. +Suggest a $weeklyGoalDays-day weekly split optimised for $goal in 3–4 sentences. +The athlete has recently trained: $recentMuscles. +Be specific (Push/Pull/Legs, Upper/Lower, etc.). Under 80 words. Plain text only, no markdown.""" + + runCatching { model(context).generateContent(prompt).text?.trim() } + .getOrNull() ?: "A Push/Pull/Legs split repeated across $weeklyGoalDays days suits your goal well." + } + + suspend fun askDayEvaluation( + context: Context, + dayName: String, + exerciseNames: List, + goalMode: String, + ): String = withContext(Dispatchers.IO) { + val goal = when (goalMode) { + "strength" -> "strength" + "general_fitness" -> "general fitness" + else -> "hypertrophy" + } + val exList = exerciseNames.take(8).joinToString(", ").ifBlank { "no exercises listed" } + + val prompt = """You are a concise personal trainer AI inside the IronLog workout app. +Evaluate this "$dayName" session for $goal: $exList. +Note any imbalances or missing movement patterns in 2–3 sentences. Under 70 words. Plain text only.""" + + runCatching { model(context).generateContent(prompt).text?.trim() } + .getOrNull() ?: "The selection looks balanced. Ensure compound movements come first for best results." + } + + suspend fun askProgressionExplanation( + context: Context, + exerciseName: String, + recentWeightKg: Double, + recentReps: Int, + trend: String, + ): String = withContext(Dispatchers.IO) { + val prompt = """You are a concise personal trainer AI inside the IronLog workout app. +$exerciseName: working weight ${recentWeightKg}kg × $recentReps reps, progress trend is $trend. +In 1–2 sentences explain the recommended next progression step. Under 50 words. Plain text only.""" + + runCatching { model(context).generateContent(prompt).text?.trim() } + .getOrNull() ?: "Aim to add a small amount of weight or an extra rep next session." + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/intelligence/MuscleContributionEngine.kt b/app/src/main/java/com/ironlog/app/domain/intelligence/MuscleContributionEngine.kt new file mode 100644 index 0000000..3befdf5 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/intelligence/MuscleContributionEngine.kt @@ -0,0 +1,398 @@ +package com.ironlog.app.domain.intelligence + +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.HistoryExercise + +// ── Fine-grained muscle list (26 muscles) ──────────────────────────────────── + +val FINE_MUSCLES = listOf( + "upperChest", "midChest", "lowerChest", + "lats", "upperBack", "traps", "spinalErectors", + "frontDelts", "sideDelts", "rearDelts", + "bicepsLong", "bicepsShort", "brachialis", + "tricepsLong", "tricepsLateral", "tricepsMedial", "forearms", + "upperAbs", "lowerAbs", "obliques", + "quads", "hamstrings", "glutes", "calves", "adductors", "abductors", +) + +/** Human-readable display name for each fine muscle key. */ +val FINE_MUSCLE_DISPLAY = mapOf( + "upperChest" to "Upper Chest", + "midChest" to "Mid Chest", + "lowerChest" to "Lower Chest", + "lats" to "Lats", + "upperBack" to "Upper Back", + "traps" to "Traps", + "spinalErectors" to "Spinal Erectors", + "frontDelts" to "Front Delts", + "sideDelts" to "Side Delts", + "rearDelts" to "Rear Delts", + "bicepsLong" to "Biceps Long", + "bicepsShort" to "Biceps Short", + "brachialis" to "Brachialis", + "tricepsLong" to "Triceps Long", + "tricepsLateral" to "Triceps Lateral", + "tricepsMedial" to "Triceps Medial", + "forearms" to "Forearms", + "upperAbs" to "Upper Abs", + "lowerAbs" to "Lower Abs", + "obliques" to "Obliques", + "quads" to "Quads", + "hamstrings" to "Hamstrings", + "glutes" to "Glutes", + "calves" to "Calves", + "adductors" to "Adductors", + "abductors" to "Abductors", +) + +/** Short abbreviation for bar chart labels (≤7 chars). */ +val FINE_MUSCLE_ABBREV = mapOf( + "upperChest" to "U.Chest", + "midChest" to "M.Chest", + "lowerChest" to "L.Chest", + "lats" to "Lats", + "upperBack" to "U.Back", + "traps" to "Traps", + "spinalErectors" to "Spine", + "frontDelts" to "F.Delts", + "sideDelts" to "S.Delts", + "rearDelts" to "R.Delt", + "bicepsLong" to "Bi.Long", + "bicepsShort" to "Bi.Shrt", + "brachialis" to "Brach.", + "tricepsLong" to "Tri.Lng", + "tricepsLateral" to "Tri.Lat", + "tricepsMedial" to "Tri.Med", + "forearms" to "Forearm", + "upperAbs" to "U.Abs", + "lowerAbs" to "L.Abs", + "obliques" to "Obliq.", + "quads" to "Quads", + "hamstrings" to "Hams", + "glutes" to "Glutes", + "calves" to "Calves", + "adductors" to "Adduct.", + "abductors" to "Abduct.", +) + +// ── Movement-family templates (fractional contribution per fine muscle) ──────── + +private val FAMILY_TEMPLATES = mapOf( + "incline_press" to mapOf("upperChest" to 0.36, "midChest" to 0.18, "frontDelts" to 0.20, "tricepsLong" to 0.08, "tricepsLateral" to 0.10, "tricepsMedial" to 0.08), + "decline_press" to mapOf("lowerChest" to 0.38, "midChest" to 0.20, "tricepsLong" to 0.08, "tricepsLateral" to 0.16, "tricepsMedial" to 0.10, "frontDelts" to 0.08), + "horizontal_press" to mapOf("midChest" to 0.38, "upperChest" to 0.16, "frontDelts" to 0.16, "tricepsLong" to 0.08, "tricepsLateral" to 0.14, "tricepsMedial" to 0.08), + "chest_fly" to mapOf("midChest" to 0.46, "upperChest" to 0.22, "lowerChest" to 0.14, "frontDelts" to 0.10, "bicepsLong" to 0.08), + "vertical_press" to mapOf("frontDelts" to 0.34, "sideDelts" to 0.22, "tricepsLong" to 0.16, "tricepsLateral" to 0.14, "tricepsMedial" to 0.08, "upperChest" to 0.06), + "vertical_pull" to mapOf("lats" to 0.42, "upperBack" to 0.16, "bicepsLong" to 0.12, "bicepsShort" to 0.12, "brachialis" to 0.08, "forearms" to 0.10), + "horizontal_pull" to mapOf("upperBack" to 0.30, "lats" to 0.26, "rearDelts" to 0.12, "traps" to 0.10, "bicepsLong" to 0.08, "bicepsShort" to 0.08, "brachialis" to 0.06), + "shrug" to mapOf("traps" to 0.56, "upperBack" to 0.22, "forearms" to 0.08, "spinalErectors" to 0.14), + "rear_delt" to mapOf("rearDelts" to 0.46, "upperBack" to 0.22, "traps" to 0.12, "sideDelts" to 0.12, "bicepsShort" to 0.08), + "lateral_raise" to mapOf("sideDelts" to 0.58, "frontDelts" to 0.14, "rearDelts" to 0.12, "traps" to 0.16), + "front_raise" to mapOf("frontDelts" to 0.62, "upperChest" to 0.16, "sideDelts" to 0.12, "traps" to 0.10), + "biceps_curl" to mapOf("bicepsLong" to 0.24, "bicepsShort" to 0.28, "brachialis" to 0.22, "forearms" to 0.26), + "hammer_curl" to mapOf("brachialis" to 0.32, "bicepsLong" to 0.20, "bicepsShort" to 0.18, "forearms" to 0.30), + "triceps_pushdown" to mapOf("tricepsLateral" to 0.38, "tricepsMedial" to 0.32, "tricepsLong" to 0.18, "forearms" to 0.12), + "triceps_overhead" to mapOf("tricepsLong" to 0.42, "tricepsLateral" to 0.26, "tricepsMedial" to 0.20, "frontDelts" to 0.12), + "squat" to mapOf("quads" to 0.36, "glutes" to 0.20, "adductors" to 0.12, "spinalErectors" to 0.10, "hamstrings" to 0.12, "calves" to 0.10), + "lunge" to mapOf("quads" to 0.28, "glutes" to 0.24, "hamstrings" to 0.14, "adductors" to 0.14, "calves" to 0.08, "abductors" to 0.12), + "hinge" to mapOf("hamstrings" to 0.28, "glutes" to 0.26, "spinalErectors" to 0.20, "adductors" to 0.10, "upperBack" to 0.08, "forearms" to 0.08), + "leg_extension" to mapOf("quads" to 0.78, "adductors" to 0.12, "calves" to 0.10), + "leg_curl" to mapOf("hamstrings" to 0.66, "glutes" to 0.12, "calves" to 0.08, "adductors" to 0.14), + "calf_raise" to mapOf("calves" to 0.84, "hamstrings" to 0.08, "quads" to 0.08), + "core_crunch" to mapOf("upperAbs" to 0.44, "lowerAbs" to 0.24, "obliques" to 0.32), + "leg_raise" to mapOf("lowerAbs" to 0.40, "upperAbs" to 0.24, "obliques" to 0.36), + "plank" to mapOf("upperAbs" to 0.26, "lowerAbs" to 0.22, "obliques" to 0.18, "spinalErectors" to 0.16, "glutes" to 0.10, "frontDelts" to 0.08), +) + +// ── Library group → fine muscle expansion ──────────────────────────────────── + +private val LIBRARY_GROUP_MAP = mapOf( + "chest" to mapOf("midChest" to 0.50, "upperChest" to 0.30, "lowerChest" to 0.20), + "shoulders" to mapOf("frontDelts" to 0.34, "sideDelts" to 0.34, "rearDelts" to 0.32), + "back" to mapOf("upperBack" to 0.42, "lats" to 0.34, "traps" to 0.14, "spinalErectors" to 0.10), + "middle back" to mapOf("upperBack" to 0.60, "traps" to 0.25, "spinalErectors" to 0.15), + "lower back" to mapOf("spinalErectors" to 0.72, "upperBack" to 0.12, "glutes" to 0.16), + "lats" to mapOf("lats" to 0.76, "upperBack" to 0.16, "bicepsShort" to 0.08), + "lat" to mapOf("lats" to 0.76, "upperBack" to 0.16, "bicepsShort" to 0.08), + "traps" to mapOf("traps" to 0.74, "upperBack" to 0.18, "rearDelts" to 0.08), + "trap" to mapOf("traps" to 0.74, "upperBack" to 0.18, "rearDelts" to 0.08), + "quadriceps" to mapOf("quads" to 0.78, "adductors" to 0.12, "calves" to 0.10), + "quads" to mapOf("quads" to 0.78, "adductors" to 0.12, "calves" to 0.10), + "hamstrings" to mapOf("hamstrings" to 0.68, "glutes" to 0.18, "adductors" to 0.14), + "glutes" to mapOf("glutes" to 0.62, "hamstrings" to 0.18, "abductors" to 0.20), + "calves" to mapOf("calves" to 0.90, "hamstrings" to 0.10), + "adductors" to mapOf("adductors" to 0.78, "quads" to 0.12, "hamstrings" to 0.10), + "abductors" to mapOf("abductors" to 0.72, "glutes" to 0.28), + "front delts" to mapOf("frontDelts" to 0.76, "sideDelts" to 0.14, "upperChest" to 0.10), + "side delts" to mapOf("sideDelts" to 0.78, "frontDelts" to 0.12, "rearDelts" to 0.10), + "rear delts" to mapOf("rearDelts" to 0.74, "upperBack" to 0.18, "traps" to 0.08), + "rotator cuff" to mapOf("rearDelts" to 0.28, "sideDelts" to 0.16, "frontDelts" to 0.16, "upperBack" to 0.12, "traps" to 0.10, "forearms" to 0.18), + "biceps" to mapOf("bicepsLong" to 0.28, "bicepsShort" to 0.32, "brachialis" to 0.20, "forearms" to 0.20), + "biceps long head" to mapOf("bicepsLong" to 0.60, "bicepsShort" to 0.16, "brachialis" to 0.10, "forearms" to 0.14), + "biceps short head" to mapOf("bicepsShort" to 0.60, "bicepsLong" to 0.16, "brachialis" to 0.10, "forearms" to 0.14), + "brachialis" to mapOf("brachialis" to 0.56, "bicepsLong" to 0.18, "bicepsShort" to 0.14, "forearms" to 0.12), + "triceps" to mapOf("tricepsLong" to 0.34, "tricepsLateral" to 0.34, "tricepsMedial" to 0.24, "forearms" to 0.08), + "triceps long head" to mapOf("tricepsLong" to 0.62, "tricepsLateral" to 0.20, "tricepsMedial" to 0.10, "frontDelts" to 0.08), + "triceps lateral head" to mapOf("tricepsLateral" to 0.62, "tricepsLong" to 0.20, "tricepsMedial" to 0.10, "forearms" to 0.08), + "triceps medial head" to mapOf("tricepsMedial" to 0.58, "tricepsLateral" to 0.20, "tricepsLong" to 0.14, "forearms" to 0.08), + "forearms" to mapOf("forearms" to 0.82, "brachialis" to 0.18), + "abdominals" to mapOf("upperAbs" to 0.42, "lowerAbs" to 0.32, "obliques" to 0.26), + "upper abs" to mapOf("upperAbs" to 0.64, "lowerAbs" to 0.18, "obliques" to 0.18), + "lower abs" to mapOf("lowerAbs" to 0.62, "upperAbs" to 0.18, "obliques" to 0.20), + "obliques" to mapOf("obliques" to 0.74, "upperAbs" to 0.14, "lowerAbs" to 0.12), + "core" to mapOf("upperAbs" to 0.34, "lowerAbs" to 0.28, "obliques" to 0.20, "spinalErectors" to 0.18), +) + +// ── Specific exercise overrides ─────────────────────────────────────────────── + +private val ANCHOR_OVERRIDES = mapOf( + "barbellbenchpress" to mapOf("upperChest" to 0.18, "midChest" to 0.42, "frontDelts" to 0.14, "tricepsLong" to 0.08, "tricepsLateral" to 0.10, "tricepsMedial" to 0.08), + "inclinebarbellbenchpress" to mapOf("upperChest" to 0.36, "midChest" to 0.16, "frontDelts" to 0.18, "tricepsLong" to 0.08, "tricepsLateral" to 0.12, "tricepsMedial" to 0.10), + "dumbbellbenchpress" to mapOf("upperChest" to 0.20, "midChest" to 0.38, "frontDelts" to 0.14, "tricepsLong" to 0.08, "tricepsLateral" to 0.10, "tricepsMedial" to 0.10), + "barbelloverheadpress" to mapOf("frontDelts" to 0.34, "sideDelts" to 0.22, "tricepsLong" to 0.16, "tricepsLateral" to 0.14, "tricepsMedial" to 0.08, "upperChest" to 0.06), + "barbellbentoverrow" to mapOf("upperBack" to 0.30, "lats" to 0.28, "rearDelts" to 0.10, "traps" to 0.12, "bicepsShort" to 0.08, "brachialis" to 0.06, "spinalErectors" to 0.06), + "barbellfullsquat" to mapOf("quads" to 0.38, "glutes" to 0.20, "adductors" to 0.12, "hamstrings" to 0.10, "spinalErectors" to 0.10, "calves" to 0.10), + "barbelldeadlift" to mapOf("hamstrings" to 0.24, "glutes" to 0.22, "spinalErectors" to 0.22, "upperBack" to 0.12, "traps" to 0.10, "forearms" to 0.10), + "romaniandeadlift" to mapOf("hamstrings" to 0.30, "glutes" to 0.26, "spinalErectors" to 0.20, "adductors" to 0.10, "forearms" to 0.06, "upperBack" to 0.08), + "latpulldown" to mapOf("lats" to 0.46, "upperBack" to 0.16, "bicepsLong" to 0.12, "bicepsShort" to 0.10, "brachialis" to 0.08, "forearms" to 0.08), + "pullup" to mapOf("lats" to 0.42, "upperBack" to 0.18, "bicepsLong" to 0.12, "bicepsShort" to 0.12, "brachialis" to 0.08, "forearms" to 0.08), + "legpress" to mapOf("quads" to 0.42, "glutes" to 0.20, "adductors" to 0.14, "hamstrings" to 0.12, "calves" to 0.12), + "legextension" to mapOf("quads" to 0.82, "adductors" to 0.10, "calves" to 0.08), + "legcurlmachine" to mapOf("hamstrings" to 0.72, "glutes" to 0.10, "calves" to 0.08, "adductors" to 0.10), + "standingcalfraise" to mapOf("calves" to 0.88, "hamstrings" to 0.06, "quads" to 0.06), + "seatedcalfraise" to mapOf("calves" to 0.90, "hamstrings" to 0.10), +) + +// ── Movement-family detection rules ────────────────────────────────────────── + +private data class FamilyRule(val id: String, val score: Int, val patterns: List) + +private val FAMILY_RULES = listOf( + FamilyRule("incline_press", 10, listOf("incline.*press", "incline.*bench", "incline.*push").map(::Regex)), + FamilyRule("decline_press", 10, listOf("decline.*press", "decline.*bench", "dip").map(::Regex)), + FamilyRule("chest_fly", 9, listOf("fly", "crossover", "pec deck").map(::Regex)), + FamilyRule("horizontal_press", 8, listOf("\\bbench\\b", "\\bpress\\b", "push.?up", "pushup").map(::Regex)), + FamilyRule("vertical_press", 10, listOf("shoulder press", "overhead press", "\\bohp\\b", "military press", "arnold press").map(::Regex)), + FamilyRule("vertical_pull", 9, listOf("pull.?up", "chin.?up", "pulldown", "lat pull").map(::Regex)), + FamilyRule("horizontal_pull", 9, listOf("\\brow\\b", "meadows", "seal row", "pullover").map(::Regex)), + FamilyRule("shrug", 8, listOf("shrug").map(::Regex)), + FamilyRule("rear_delt", 8, listOf("rear delt", "reverse pec", "reverse fly", "face pull").map(::Regex)), + FamilyRule("lateral_raise", 8, listOf("lateral raise", "side raise").map(::Regex)), + FamilyRule("front_raise", 7, listOf("front raise").map(::Regex)), + FamilyRule("hammer_curl", 9, listOf("hammer curl", "rope hammer").map(::Regex)), + FamilyRule("biceps_curl", 8, listOf("curl", "preacher", "concentration", "zottman").map(::Regex)), + FamilyRule("triceps_overhead", 9, listOf("overhead extension", "skull crusher", "skullcrusher", "lying extension").map(::Regex)), + FamilyRule("triceps_pushdown", 8, listOf("pushdown", "kickback").map(::Regex)), + FamilyRule("hinge", 10, listOf("deadlift", "\\brdl\\b", "romanian", "good morning", "hip thrust", "glute bridge").map(::Regex)), + FamilyRule("lunge", 9, listOf("split squat", "lunge", "step.?up", "walking lunge").map(::Regex)), + FamilyRule("squat", 10, listOf("squat", "hack squat", "leg press", "belt squat").map(::Regex)), + FamilyRule("leg_extension", 10, listOf("leg extension").map(::Regex)), + FamilyRule("leg_curl", 10, listOf("leg curl", "nordic", "glute ham").map(::Regex)), + FamilyRule("calf_raise", 10, listOf("calf").map(::Regex)), + FamilyRule("core_crunch", 10, listOf("crunch", "sit.?up", "cable crunch").map(::Regex)), + FamilyRule("leg_raise", 10, listOf("leg raise", "toes to bar", "v.?up", "hanging knee").map(::Regex)), + FamilyRule("plank", 9, listOf("plank", "ab wheel", "hollow", "carry").map(::Regex)), +) + +// ── Cross-system grouping maps ──────────────────────────────────────────────── + +/** + * Maps each fine muscle to a Push / Pull / Legs / Core training-split bucket. + * Used by TrainingIntelligenceEngine for volume landmarks and movement balance. + */ +val FINE_MUSCLE_TO_PPL: Map = mapOf( + // Push + "upperChest" to "Push", "midChest" to "Push", "lowerChest" to "Push", + "frontDelts" to "Push", "sideDelts" to "Push", + "tricepsLong" to "Push", "tricepsLateral" to "Push", "tricepsMedial" to "Push", + // Pull + "lats" to "Pull", "upperBack" to "Pull", "traps" to "Pull", + "spinalErectors" to "Pull", "rearDelts" to "Pull", + "bicepsLong" to "Pull", "bicepsShort" to "Pull", "brachialis" to "Pull", + "forearms" to "Pull", + // Legs + "quads" to "Legs", "hamstrings" to "Legs", "glutes" to "Legs", + "calves" to "Legs", "adductors" to "Legs", "abductors" to "Legs", + // Core + "upperAbs" to "Core", "lowerAbs" to "Core", "obliques" to "Core", +) + +/** + * Maps each fine muscle to its recovery region (Push / Pull / Legs / Arms / Shoulders / Core). + * Used by RecoveryReadinessEngine for the body-map heatmap. + */ +val FINE_MUSCLE_TO_REGION: Map = mapOf( + // Chest → Push recovery + "upperChest" to "Push", "midChest" to "Push", "lowerChest" to "Push", + // Back → Pull recovery + "lats" to "Pull", "upperBack" to "Pull", "traps" to "Pull", + "spinalErectors" to "Pull", + // Delts → Shoulders recovery + "frontDelts" to "Shoulders", "sideDelts" to "Shoulders", "rearDelts" to "Shoulders", + // Elbow flexors / extensors → Arms recovery + "bicepsLong" to "Arms", "bicepsShort" to "Arms", "brachialis" to "Arms", + "tricepsLong" to "Arms", "tricepsLateral" to "Arms", "tricepsMedial" to "Arms", + "forearms" to "Arms", + // Midsection → Core recovery + "upperAbs" to "Core", "lowerAbs" to "Core", "obliques" to "Core", + // Lower body → Legs recovery + "quads" to "Legs", "hamstrings" to "Legs", "glutes" to "Legs", + "calves" to "Legs", "adductors" to "Legs", "abductors" to "Legs", +) + +/** + * Maps each fine muscle to a radar-chart bucket (Chest / Back / Arms / Shoulders / Legs / Core). + * Used by VolumeAnalyticsScreen to fold granular data into the 6-axis radar. + */ +val FINE_MUSCLE_TO_RADAR: Map = mapOf( + "upperChest" to "Chest", "midChest" to "Chest", "lowerChest" to "Chest", + "lats" to "Back", "upperBack" to "Back", "traps" to "Back", + "spinalErectors" to "Back", + "frontDelts" to "Shoulders", "sideDelts" to "Shoulders", "rearDelts" to "Shoulders", + "bicepsLong" to "Arms", "bicepsShort" to "Arms", "brachialis" to "Arms", + "tricepsLong" to "Arms", "tricepsLateral" to "Arms", "tricepsMedial" to "Arms", + "forearms" to "Arms", + "upperAbs" to "Core", "lowerAbs" to "Core", "obliques" to "Core", + "quads" to "Legs", "hamstrings" to "Legs", "glutes" to "Legs", + "calves" to "Legs", "adductors" to "Legs", "abductors" to "Legs", +) + +/** + * Folds a fine-muscle contribution map (muscle → fraction, summing ≈1.0) into coarse region + * buckets using [regionMap], weighting fractions by the number of working sets. + */ +fun foldContributions(contributions: Map, regionMap: Map): Map { + val out = mutableMapOf() + contributions.forEach { (muscle, frac) -> + val bucket = regionMap[muscle] ?: return@forEach + out[bucket] = (out[bucket] ?: 0.0) + frac + } + return out +} + +/** + * Folds the output of [computeGranularVolume] into coarse radar buckets (Chest/Back/…) + * using [FINE_MUSCLE_TO_RADAR], returning Int set counts (rounded). + * The [axes] list defines which buckets must always appear in the output. + */ +fun foldGranularToRadar(granular: Map, axes: List): Map { + val out = axes.associateWith { 0f }.toMutableMap() + granular.forEach { (muscle, sets) -> + val bucket = FINE_MUSCLE_TO_RADAR[muscle] ?: return@forEach + if (bucket in out) out[bucket] = (out[bucket] ?: 0f) + sets + } + return out.mapValues { it.value.toInt() } +} + +// ── Core engine functions ───────────────────────────────────────────────────── + +private fun normalizeKey(name: String): String = + name.lowercase().replace(Regex("[^a-z0-9]+"), "") + +internal fun detectFamily(name: String): String { + val text = name.lowercase() + var best = "" to 0 + FAMILY_RULES.forEach { rule -> + if (rule.score > best.second && rule.patterns.any { it.containsMatchIn(text) }) { + best = rule.id to rule.score + } + } + // Muscle-name fallback if no pattern matched + if (best.second == 0) { + return when { + "chest" in text -> "horizontal_press" + "lat" in text || "back" in text -> "horizontal_pull" + "squat" in text || "quad" in text -> "squat" + "deadlift" in text || "hinge" in text || "glute" in text -> "hinge" + "shoulder" in text || "delt" in text -> "vertical_press" + "curl" in text || "bicep" in text -> "biceps_curl" + "tricep" in text || "extension" in text -> "triceps_pushdown" + "calf" in text -> "calf_raise" + "ab" in text || "core" in text -> "core_crunch" + else -> "" + } + } + return best.first +} + +private fun normalize(raw: Map): Map { + val cleaned = raw.filter { (k, _) -> k in FINE_MUSCLES } + val total = cleaned.values.sum().takeIf { it > 0 } ?: return emptyMap() + return cleaned.mapValues { it.value / total } +} + +private fun mergeWeighted(a: Map, wA: Double, b: Map, wB: Double): Map { + val merged = linkedMapOf() + a.forEach { (k, v) -> merged[k] = (merged[k] ?: 0.0) + v * wA } + b.forEach { (k, v) -> merged[k] = (merged[k] ?: 0.0) + v * wB } + return normalize(merged) +} + +private fun libraryContribution(exercise: HistoryExercise): Map { + val hints = (exercise.primaryMuscles + listOfNotNull(exercise.primaryMuscle)) + .map { it.trim().lowercase() } + .filter { it.isNotBlank() } + val merged = linkedMapOf() + hints.forEach { hint -> + LIBRARY_GROUP_MAP[hint]?.forEach { (k, v) -> merged[k] = (merged[k] ?: 0.0) + v } + } + return normalize(merged) +} + +/** Resolve fractional contribution map for a single exercise (sums to ≈1.0). */ +fun resolveContribution(exercise: HistoryExercise): Map { + // 1. Anchor override — exercise-specific ground truth + val anchorKey = normalizeKey(exercise.name) + ANCHOR_OVERRIDES[anchorKey]?.let { return normalize(it) } + + // 2. Family template from movement pattern detection + val family = detectFamily(exercise.name) + val template = FAMILY_TEMPLATES[family]?.let(::normalize) ?: emptyMap() + + // 3. Library expansion from primaryMuscles metadata + val library = libraryContribution(exercise) + + return when { + template.isNotEmpty() && library.isNotEmpty() -> mergeWeighted(template, 0.72, library, 0.28) + template.isNotEmpty() -> template + library.isNotEmpty() -> library + else -> emptyMap() + } +} + +/** + * Aggregate fractional effective sets per fine muscle across a workout history. + * Each working set contributes [contribution_fraction] effective sets to each fine muscle. + * Returns map sorted descending, keys are camelCase FINE_MUSCLES keys. + */ +fun computeGranularVolume(history: List): Map { + val totals = linkedMapOf() + history.forEach { workout -> + workout.exercises.forEach { ex -> + val workingSets = ex.sets.count { it.type != "warmup" } + if (workingSets > 0) { + val contrib = resolveContribution(ex) + if (contrib.isNotEmpty()) { + contrib.forEach { (muscle, fraction) -> + totals[muscle] = (totals[muscle] ?: 0.0) + workingSets * fraction + } + } else { + // Fallback: credit the broad primaryMuscle group + val broad = (ex.primaryMuscle ?: ex.primaryMuscles.firstOrNull()).orEmpty().lowercase() + val fallback = LIBRARY_GROUP_MAP[broad] + if (fallback != null) { + val norm = normalize(fallback) + norm.forEach { (muscle, fraction) -> + totals[muscle] = (totals[muscle] ?: 0.0) + workingSets * fraction + } + } + } + } + } + } + return totals + .filter { it.value >= 0.05 } + .entries + .sortedByDescending { it.value } + .associate { it.key to it.value.toFloat() } +} diff --git a/app/src/main/java/com/ironlog/app/domain/intelligence/PrLinkingEngine.kt b/app/src/main/java/com/ironlog/app/domain/intelligence/PrLinkingEngine.kt new file mode 100644 index 0000000..ee56781 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/intelligence/PrLinkingEngine.kt @@ -0,0 +1,41 @@ +package com.ironlog.app.domain.intelligence + +import com.ironlog.app.util.normalizeExerciseNameKey + +object PrLinkingEngine { + private val aliases = linkedMapOf( + "barbell_bench_press" to "bench_press", + "dumbbell_bench_press" to "bench_press", + "incline_barbell_bench_press" to "incline_press", + "incline_dumbbell_press" to "incline_press", + "lat_pulldown" to "vertical_pull", + "weighted_pull_up" to "vertical_pull", + "pull_up" to "vertical_pull", + "barbell_row" to "row", + "seated_cable_row" to "row", + "romanian_deadlift" to "hip_hinge", + "deadlift" to "hip_hinge", + "back_squat" to "squat", + "front_squat" to "squat", + ) + + fun getPrGroupId( + name: String, + primaryMuscle: String?, + equipment: String?, + category: String?, + mode: String = "safe", + ): String? { + val normalized = normalizeExerciseNameKey(name) + if (normalized.isBlank()) return null + val canonical = aliases[normalized] ?: normalized + val muscleKey = normalizeExerciseNameKey(primaryMuscle.orEmpty()).ifBlank { "unknown_muscle" } + val equipmentKey = normalizeExerciseNameKey(equipment.orEmpty()).ifBlank { "unknown_equipment" } + val categoryKey = normalizeExerciseNameKey(category.orEmpty()).ifBlank { "general" } + return when (mode.lowercase()) { + "strict" -> "strict_${equipmentKey}_${canonical}" + "broad" -> "broad_${muscleKey}_${categoryKey}_${canonical}" + else -> "safe_${muscleKey}_${canonical}" + } + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/intelligence/RecoveryReadinessEngine.kt b/app/src/main/java/com/ironlog/app/domain/intelligence/RecoveryReadinessEngine.kt new file mode 100644 index 0000000..5ffe64b --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/intelligence/RecoveryReadinessEngine.kt @@ -0,0 +1,146 @@ +package com.ironlog.app.domain.intelligence + +import com.ironlog.app.data.health.BiometricSnapshot +import com.ironlog.app.domain.gamification.parseHistoryInstant +import com.ironlog.app.ui.model.HistoryEntry +import java.time.Instant +import kotlin.math.exp +import kotlin.math.roundToInt +import kotlinx.serialization.Serializable + +@Serializable +data class ManualRecoveryInput( + val soreness: Int = 0, + val sleepQuality: Int = 0, + val energy: Int = 0, + val notes: String = "", + val recordedAt: Long = 0L +) + +data class RecoveryScore(val score: Int, val state: String, val explanation: String) + +object RecoveryReadinessEngine { + /** + * GAP-20: Pain-flagged regions are forced to 0.0 readiness regardless of training load. + * All other regions are computed normally. + */ + fun readinessByRegion( + history: List, + painFlags: Set = emptySet(), + nowEpochMs: Long = System.currentTimeMillis(), + ): Map { + val load = mutableMapOf("Push" to 0.0, "Pull" to 0.0, "Legs" to 0.0, "Core" to 0.0, "Arms" to 0.0, "Shoulders" to 0.0) + history.forEach { w -> + val t = parseHistoryInstant(w.date)?.toEpochMilli() ?: return@forEach + val hours = ((nowEpochMs - t).coerceAtLeast(0) / 3_600_000.0) + val decay = exp(-0.03 * hours) + w.exercises.forEach { ex -> + val workingSets = ex.sets.count { it.type != "warmup" }.toDouble() + if (workingSets > 0) { + val contrib = resolveContribution(ex) + if (contrib.isNotEmpty()) { + // Distribute load across recovery regions proportionally + val regionFold = foldContributions(contrib, FINE_MUSCLE_TO_REGION) + regionFold.forEach { (region, frac) -> + load[region] = (load[region] ?: 0.0) + workingSets * frac * decay + } + } else { + // Fallback: coarse keyword classification + val key = when ((ex.primaryMuscle ?: ex.primaryMuscles.firstOrNull().orEmpty()).lowercase()) { + "chest" -> "Push" + "back", "lats" -> "Pull" + "quads", "hamstrings", "glutes", "calves", "legs", "leg" -> "Legs" + "biceps", "triceps", "arms", "forearms" -> "Arms" + "shoulders", "delts" -> "Shoulders" + else -> "Core" + } + load[key] = (load[key] ?: 0.0) + workingSets * decay + } + } + } + } + val base = load.mapValues { (_, v) -> (1.0 - (v / 8.0).coerceIn(0.0, 1.0)).coerceAtLeast(0.05) } + return if (painFlags.isEmpty()) base else base.mapValues { (region, v) -> if (region in painFlags) 0.0 else v } + } + + private fun scoreFromManualInput(input: ManualRecoveryInput?, nowEpochMs: Long): Int? { + if (input == null) return null + if (input.soreness == 0 && input.sleepQuality == 0 && input.energy == 0) return null + // Ensure manual input isn't too stale (e.g., > 48 hours) + if (input.recordedAt <= 0L || nowEpochMs - input.recordedAt > 48 * 3600_000L) return null + + val s = input.soreness.toDouble() + val sl = input.sleepQuality.toDouble() + val e = input.energy.toDouble() + val normalized = (((sl + e) / 10.0) - (s / 10.0)).coerceIn(-1.0, 1.0) + return (normalized * 15.0).roundToInt() + } + + fun score( + readiness: Map, + manualInput: ManualRecoveryInput? = null, + nowEpochMs: Long = System.currentTimeMillis(), + ): RecoveryScore { + val rows = readiness.values.filter { it.isFinite() } + val baseScore = if (rows.isEmpty()) 70 else ((rows.average() * 100).coerceIn(1.0, 99.0)).roundToInt() + + val manualOffset = scoreFromManualInput(manualInput, nowEpochMs) + val s = (baseScore + (manualOffset ?: 0)).coerceIn(1, 99) + + val state = if (s >= 78) "fresh" else if (s >= 55) "recovering" else "fatigued" + val explanation = if (manualOffset == null) { + "Based on recent muscle workload and time since training." + } else { + "Blended from workload history and your soreness/sleep/energy check-in." + } + return RecoveryScore(s, state, explanation) + } + + fun suggestions(readiness: Map): List { + val rows = readiness.entries.sortedByDescending { it.value } + if (rows.isEmpty()) return emptyList() + val top = rows.first() + val low = rows.last() + val out = mutableListOf("${top.key} is most recovered (${(top.value * 100).roundToInt()}%).") + if (top.key != low.key) out += "${low.key} is least recovered (${(low.value * 100).roundToInt()}%), reduce volume." + return out + } + + /** + * Blends a training-load [readinessScore] (0.0–1.0) with biometric data + * from Health Connect. + * + * Weights: + * - Training load readiness: 60% + * - Sleep quality: 25% + * - HRV: 15% + * + * If no biometric data is available (all nulls), returns [readinessScore] unchanged. + * + * @param readinessScore Overall score from [score] (0.0 = very fatigued, 1.0 = fully ready). + * @param biometrics Latest snapshot from HealthConnectRepository.readBiometricSnapshot(). + */ + fun blendWithBiometric(readinessScore: Double, biometrics: BiometricSnapshot): Double { + fun sleepScore(hours: Double) = ((hours - 4.0) / 4.0).coerceIn(0.0, 1.0) + fun hrvScore(rmssd: Double) = ((rmssd - 20.0) / 60.0).coerceIn(0.0, 1.0) + + val hasSleep = biometrics.sleepHours != null + val hasHrv = biometrics.hrvRmssd != null + + if (!hasSleep && !hasHrv) return readinessScore + + var weightedSum = readinessScore * 0.60 + var totalWeight = 0.60 + + if (hasSleep) { + weightedSum += sleepScore(biometrics.sleepHours!!) * 0.25 + totalWeight += 0.25 + } + if (hasHrv) { + weightedSum += hrvScore(biometrics.hrvRmssd!!) * 0.15 + totalWeight += 0.15 + } + + return (weightedSum / totalWeight).coerceIn(0.0, 1.0) + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/intelligence/SubstitutionEngine.kt b/app/src/main/java/com/ironlog/app/domain/intelligence/SubstitutionEngine.kt new file mode 100644 index 0000000..e99361d --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/intelligence/SubstitutionEngine.kt @@ -0,0 +1,43 @@ +package com.ironlog.app.domain.intelligence + +import com.ironlog.app.data.model.ExerciseFilters +import com.ironlog.app.data.model.LegacyExerciseShape + +object SubstitutionEngine { + data class AlternativesResult( + val bestMatches: List, + val sameMuscle: List, + val sameEquipment: List, + val fallback: List, + ) { + companion object { val EMPTY = AlternativesResult(emptyList(), emptyList(), emptyList(), emptyList()) } + } + + fun buildExerciseAlternatives( + exercise: LegacyExerciseShape, + candidates: List, + filters: ExerciseFilters, + limit: Int, + ): AlternativesResult { + val pool = candidates + .asSequence() + .filter { it.id != exercise.id } + .filter { filters.primaryMuscle?.let { m -> it.primaryMuscle.equals(m, true) } ?: true } + .filter { filters.equipment?.let { e -> it.equipment.equals(e, true) } ?: true } + .filter { filters.category?.let { c -> it.category.equals(c, true) } ?: true } + .toList() + val scored = pool.map { candidate -> + val muscleScore = if (candidate.primaryMuscle.equals(exercise.primaryMuscle, true)) 3 else 0 + val equipScore = if (candidate.equipment.equals(exercise.equipment, true)) 2 else 0 + val patternScore = if (candidate.movementPattern.equals(exercise.movementPattern, true)) 2 else 0 + val categoryScore = if (candidate.category.equals(exercise.category, true)) 1 else 0 + val total = muscleScore + equipScore + patternScore + categoryScore + candidate to total + }.sortedByDescending { it.second } + val best = scored.filter { it.second >= 5 }.map { it.first }.take(limit) + val sameMuscle = scored.filter { it.first.primaryMuscle.equals(exercise.primaryMuscle, true) }.map { it.first }.take(limit) + val sameEquipment = scored.filter { it.first.equipment.equals(exercise.equipment, true) }.map { it.first }.take(limit) + val fallback = scored.map { it.first }.take(limit) + return AlternativesResult(best, sameMuscle, sameEquipment, fallback) + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/intelligence/TrainingIntelligenceEngine.kt b/app/src/main/java/com/ironlog/app/domain/intelligence/TrainingIntelligenceEngine.kt new file mode 100644 index 0000000..ec22e4d --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/intelligence/TrainingIntelligenceEngine.kt @@ -0,0 +1,357 @@ +package com.ironlog.app.domain.intelligence + +import com.ironlog.app.domain.gamification.parseHistoryInstant +import com.ironlog.app.ui.model.HistoryEntry +import java.time.LocalDate +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import java.time.temporal.WeekFields +import kotlin.math.max +import kotlin.math.roundToInt + +data class VolumeLandmark(val sets: Int, val status: String, val min: Int, val max: Int, val optimal: Int) + +data class NeuralFatigueResult( + val isFlagged: Boolean, + val consecutiveDays: Int, + val lastHeavyExercises: List = emptyList(), +) + +data class TrainingIntelligenceSnapshot( + /** 6 anatomical groups (Chest/Back/Legs/Shoulders/Arms/Core), based on current ISO week. */ + val setsByMuscle: Map, + val volumeLandmarks: Map, + /** Push = Chest+Shoulders %, Pull = Back %, Legs = Legs % */ + val movementBalance: Map, + val prLast30: Int, + val prPrev30: Int, + val prTrend: String, // "accelerating" | "steady" | "slowing" + val prVelocity30d: Float, // prLast30 / session count (0-1) + val bestWindow: String, + val trainingAgeYears: Double, + val trainingAgeLabel: String, + val trainingAgeTip: String, + val neuralFatigue: NeuralFatigueResult, +) + +object TrainingIntelligenceEngine { + + // 6 anatomical groups — matches RN's MUSCLE_GROUPS / VOLUME_LANDMARKS + private val VOLUME_LANDMARKS_MAP = mapOf( + "Chest" to Triple(10, 20, 14), + "Back" to Triple(10, 22, 16), + "Legs" to Triple(12, 22, 16), + "Shoulders" to Triple(8, 16, 12), + "Arms" to Triple(8, 16, 12), + "Core" to Triple(6, 16, 10), + ) + + private val HEAVY_COMPOUNDS = setOf( + "squat", "deadlift", "bench press", "overhead press", "ohp", "barbell row", + "power clean", "front squat", "sumo", "romanian", + ) + + /** Warmup builder for working sets; rounds to nearest 2.5kg and keeps at least bar weight. */ + fun generateWarmupSets(workingWeightKg: Double, barWeightKg: Double = 20.0): List> { + val base = workingWeightKg.coerceAtLeast(barWeightKg) + val steps = listOf( + 0.40 to 8, + 0.55 to 5, + 0.70 to 3, + 0.85 to 1, + ) + return steps.map { (pct, reps) -> + val w = ((base * pct) / 2.5).roundToInt() * 2.5 + val clamped = w.coerceAtLeast(barWeightKg.coerceAtMost(base)) + clamped to reps + }.distinctBy { it.first to it.second } + } + + fun build( + history: List, + @Suppress("UNUSED_PARAMETER") prCount: Int = 0, + activeDraftContribution: Map = emptyMap(), + ): TrainingIntelligenceSnapshot { + val last30 = history.filter { ageDays(it.date) <= 30 } + val byGroup = setsByGroup(history, activeDraftContribution) + val (prLast30, prPrev30, prTrend) = computePrVelocity(history) + val velocity = if (last30.isEmpty()) 0f + else (prLast30.toFloat() / last30.size.toFloat()).coerceIn(0f, 1f) + val (years, label, tip) = computeTrainingAge(history) + return TrainingIntelligenceSnapshot( + setsByMuscle = byGroup, + volumeLandmarks = computeVolumeLandmarks(byGroup), + movementBalance = movementBalance(byGroup), + prLast30 = prLast30, + prPrev30 = prPrev30, + prTrend = prTrend, + prVelocity30d = velocity, + bestWindow = bestPerformanceWindow(last30), + trainingAgeYears = years, + trainingAgeLabel = label, + trainingAgeTip = tip, + neuralFatigue = computeNeuralFatigue(history), + ) + } + + /** Public entry-point for VolumeInterpretationEngine and other callers. */ + fun buildLandmarks(byMuscle: Map): Map = + computeVolumeLandmarks(byMuscle) + + // ── Volume landmarks ────────────────────────────────────────────────────── + + private fun computeVolumeLandmarks(byGroup: Map): Map { + val result = linkedMapOf() + VOLUME_LANDMARKS_MAP.forEach { (group, triple) -> + val (min, max, optimal) = triple + val sets = byGroup[group] ?: 0 + val status = when { + sets < min -> "low" + sets > max -> "high" + else -> "optimal" + } + result[group] = VolumeLandmark(sets = sets, status = status, min = min, max = max, optimal = optimal) + } + return result + } + + // ── Sets-by-group (current ISO week only) ───────────────────────────────── + + /** + * Accumulates working sets per 6 anatomical group using [FINE_MUSCLE_TO_RADAR]. + * Uses the current ISO week (Mon–now) to match RN's weekly volume card behaviour. + */ + private fun setsByGroup(history: List, activeDraftContribution: Map): Map { + val thisWeekStart = LocalDate.now().with(WeekFields.ISO.dayOfWeek(), 1L) + val weekHistory = history.filter { entry -> + val d = parseHistoryInstant(entry.date)?.atZone(ZoneId.systemDefault())?.toLocalDate() + ?: return@filter false + !d.isBefore(thisWeekStart) + } + val accumulator = linkedMapOf( + "Chest" to 0.0, "Back" to 0.0, "Legs" to 0.0, + "Shoulders" to 0.0, "Arms" to 0.0, "Core" to 0.0, + ) + weekHistory.forEach { w -> + w.exercises.forEach { ex -> + val n = ex.sets.count { it.type != "warmup" } + if (n > 0) { + val contrib = resolveContribution(ex) + if (contrib.isNotEmpty()) { + foldContributions(contrib, FINE_MUSCLE_TO_RADAR).forEach { (bucket, frac) -> + accumulator[bucket] = (accumulator[bucket] ?: 0.0) + n * frac + } + } else { + val bucket = dominantRadarBucket(ex) + accumulator[bucket] = (accumulator[bucket] ?: 0.0) + n + } + } + } + } + val base = accumulator.mapValues { it.value.roundToInt() }.toMutableMap() + // Draft contribution: PPL keys title-cased → map to radar buckets + activeDraftContribution.forEach { (k, v) -> + val key = k.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + val radarKey = when (key) { + "Push" -> "Chest"; "Pull" -> "Back" + "Legs" -> "Legs"; "Core" -> "Core" + else -> if (key in base) key else null + } + if (radarKey != null && v > 0) base[radarKey] = (base[radarKey] ?: 0) + v + } + return base + } + + private fun dominantRadarBucket(ex: com.ironlog.app.ui.model.HistoryExercise): String { + val t = (listOfNotNull(ex.primaryMuscle) + ex.primaryMuscles + + listOf(ex.name, ex.category.orEmpty())).joinToString(" ").lowercase() + return when { + t.contains("chest") || t.contains("pec") -> "Chest" + t.contains("back") || t.contains("lat") || t.contains("row") -> "Back" + t.contains("shoulder") || t.contains("delt") -> "Shoulders" + t.contains("bicep") || t.contains("tricep") || t.contains("curl") -> "Arms" + t.contains("quad") || t.contains("hamstring") || t.contains("glute") || + t.contains("calf") || t.contains("leg") || t.contains("squat") || + t.contains("deadlift") -> "Legs" + else -> "Core" + } + } + + // ── Movement balance ────────────────────────────────────────────────────── + + /** Push = Chest + Shoulders, Pull = Back, Legs = Legs. Arms / Core excluded from ratio. */ + private fun movementBalance(byGroup: Map): Map { + val push = (byGroup["Chest"] ?: 0) + (byGroup["Shoulders"] ?: 0) + val pull = byGroup["Back"] ?: 0 + val legs = byGroup["Legs"] ?: 0 + val total = max(1, push + pull + legs) + return linkedMapOf( + "Push" to ((push * 100f) / total).toInt(), + "Pull" to ((pull * 100f) / total).toInt(), + "Legs" to ((legs * 100f) / total).toInt(), + ) + } + + // ── PR velocity ─────────────────────────────────────────────────────────── + + /** + * Counts PR sessions in the last 30 and previous 30-60 days by tracking + * running e1rm (Epley formula) per exercise across chronologically sorted history. + */ + private fun computePrVelocity(history: List): Triple { + val sorted = history.sortedBy { it.date } // oldest first + val runningBest = mutableMapOf() // exercise key → best e1rm + var prLast30 = 0 + var prPrev30 = 0 + sorted.forEach { session -> + val age = ageDays(session.date) + var sessionIsPr = false + session.exercises.forEach { ex -> + val key = ex.exerciseId.ifBlank { ex.name } + val maxE1rm = ex.sets + .filter { it.type != "warmup" } + .maxOfOrNull { s -> + val w = s.weight; val r = s.reps + if (w > 0 && r > 0) w * (1.0 + r / 30.0) else 0.0 + } ?: 0.0 + if (maxE1rm > 0.0) { + val prev = runningBest[key] ?: 0.0 + if (maxE1rm > prev) { + runningBest[key] = maxE1rm + sessionIsPr = true + } + } + } + if (sessionIsPr) when { + age <= 30L -> prLast30++ + age <= 60L -> prPrev30++ + } + } + val trend = when { + prLast30 > prPrev30 -> "accelerating" + prLast30 < prPrev30 -> "slowing" + else -> "steady" + } + return Triple(prLast30, prPrev30, trend) + } + + // ── Neural fatigue ──────────────────────────────────────────────────────── + + /** + * Checks for consecutive calendar days with heavy compound work in the last 14 days. + * Flagged when consecutive_days >= 3. + */ + private fun computeNeuralFatigue(history: List): NeuralFatigueResult { + val heavyDays = history + .filter { ageDays(it.date) <= 14 } + .filter { session -> + session.exercises.any { ex -> + val lower = ex.name.lowercase() + HEAVY_COMPOUNDS.any { lower.contains(it) } && + ex.sets.any { it.type != "warmup" && it.weight > 0 } + } + } + .sortedByDescending { it.date } // most recent first + + var consecutive = 0 + var prev: LocalDate? = null + val lastHeavyEx = mutableListOf() + + for (session in heavyDays) { + val day = parseHistoryInstant(session.date)?.atZone(ZoneId.systemDefault())?.toLocalDate() ?: break + val gap = if (prev == null) 0L else ChronoUnit.DAYS.between(day, prev) + if (prev == null || gap == 1L) { + consecutive++ + session.exercises + .filter { ex -> HEAVY_COMPOUNDS.any { ex.name.lowercase().contains(it) } } + .forEach { if (lastHeavyEx.size < 3 && it.name !in lastHeavyEx) lastHeavyEx += it.name } + prev = day + } else { + break + } + } + return NeuralFatigueResult( + isFlagged = consecutive >= 3, + consecutiveDays = consecutive, + lastHeavyExercises = lastHeavyEx, + ) + } + + // ── Best performance window ─────────────────────────────────────────────── + + private fun bestPerformanceWindow(history: List): String { + val buckets = linkedMapOf( + "Morning" to mutableListOf(), + "Afternoon" to mutableListOf(), + "Evening" to mutableListOf(), + "Night" to mutableListOf(), + ) + history.forEach { w -> + val hour = parseHistoryInstant(w.date)?.atZone(ZoneId.systemDefault())?.hour ?: return@forEach + val b = when (hour) { + in 5..11 -> "Morning"; in 12..16 -> "Afternoon" + in 17..20 -> "Evening"; else -> "Night" + } + buckets[b]?.add(w.volume) + } + val best = buckets.maxByOrNull { (_, v) -> if (v.isEmpty()) 0.0 else v.average() }?.key ?: "Morning" + return "$best has the highest average session volume." + } + + // ── Training age ────────────────────────────────────────────────────────── + + private fun computeTrainingAge(history: List): Triple { + if (history.isEmpty()) return Triple( + 0.0, "Beginner", "Track your first workout to start measuring progress." + ) + val oldest = history.minByOrNull { it.date } + ?: return Triple(0.0, "Beginner", "") + val oldestDate = parseHistoryInstant(oldest.date)?.atZone(ZoneId.systemDefault())?.toLocalDate() + ?: return Triple(0.0, "Beginner", "") + val months = ChronoUnit.MONTHS.between(oldestDate, LocalDate.now()).coerceAtLeast(1) + val years = months / 12.0 + + // Estimate monthly e1rm progression from compound sets + val compoundKeywords = setOf("bench", "squat", "deadlift", "press", "row") + fun sessionAvgE1rm(s: HistoryEntry) = s.exercises + .filter { ex -> compoundKeywords.any { ex.name.lowercase().contains(it) } } + .flatMap { ex -> + ex.sets.filter { it.type != "warmup" }.mapNotNull { set -> + val w = set.weight; val r = set.reps + if (w > 0 && r > 0) w * (1.0 + r / 30.0) else null + } + }.average().takeIf { it.isFinite() } + + val compoundSessions = history + .sortedBy { it.date } + .filter { sessionAvgE1rm(it) != null } + + val monthlyProgression: Double = if (compoundSessions.size >= 8) { + val avgFirst = compoundSessions.take(4).mapNotNull { sessionAvgE1rm(it) }.average() + val avgLast = compoundSessions.takeLast(4).mapNotNull { sessionAvgE1rm(it) }.average() + ((avgLast - avgFirst) / months).takeIf { it.isFinite() } ?: 0.0 + } else { + if (months < 6) 5.0 else 1.0 + } + + val (label, tip) = when { + monthlyProgression > 3.0 || months < 6 -> + "Beginner" to "Linear progression works best — add weight to each session." + monthlyProgression < 0.5 && months > 18 -> + "Advanced" to "Periodization matters — vary intensity and volume week to week." + months > 36 -> + "Elite" to "Consistency and injury prevention are your highest priorities." + else -> + "Intermediate" to "Block periodization: focus one quality per cycle (strength, volume, peak)." + } + return Triple(years, label, tip) + } + + // ── Shared helpers ──────────────────────────────────────────────────────── + + private fun ageDays(iso: String): Long { + val d = parseHistoryInstant(iso)?.atZone(ZoneId.systemDefault())?.toLocalDate() + ?: return Long.MAX_VALUE + return ChronoUnit.DAYS.between(d, LocalDate.now()) + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/intelligence/VolumeInterpretationEngine.kt b/app/src/main/java/com/ironlog/app/domain/intelligence/VolumeInterpretationEngine.kt new file mode 100644 index 0000000..6533d32 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/intelligence/VolumeInterpretationEngine.kt @@ -0,0 +1,39 @@ +package com.ironlog.app.domain.intelligence + +object VolumeInterpretationEngine { + + /** + * Generates a one-line insight string from pre-computed volume landmarks. + * Prefers muscles that are out of range (low/high) first, then summarises the rest. + */ + fun buildMuscleInsight(volumeLandmarks: Map): String { + if (volumeLandmarks.isEmpty()) return "Log workouts to see volume interpretation." + + // Muscles outside optimal range first + val outOfRange = volumeLandmarks.filter { it.value.status != "optimal" } + val optimal = volumeLandmarks.filter { it.value.status == "optimal" } + + val parts = mutableListOf() + outOfRange.forEach { (muscle, lm) -> + val verb = if (lm.status == "low") "below MEV" else "above MRV" + parts += "$muscle is $verb (${lm.sets} sets)" + } + when { + optimal.size == volumeLandmarks.size -> + parts += "All groups are in optimal range" + optimal.isNotEmpty() -> + parts += "${optimal.keys.joinToString(", ")} ${if (optimal.size == 1) "is" else "are"} optimal" + } + return parts.joinToString(". ").trimEnd('.') + "." + } + + /** + * Legacy overload — accepts a raw set-count map and derives status using + * TrainingIntelligenceEngine's landmark thresholds. Kept for backward compatibility. + */ + @JvmName("buildMuscleInsightFromSets") + fun buildMuscleInsight(volumeByMuscle: Map): String { + val landmarks = TrainingIntelligenceEngine.buildLandmarks(volumeByMuscle) + return buildMuscleInsight(landmarks) + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/intelligence/WorkoutSuggestionEngine.kt b/app/src/main/java/com/ironlog/app/domain/intelligence/WorkoutSuggestionEngine.kt new file mode 100644 index 0000000..37558f3 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/intelligence/WorkoutSuggestionEngine.kt @@ -0,0 +1,69 @@ +package com.ironlog.app.domain.intelligence + +/** + * Maps plan days to muscle-group readiness scores and recommends the best + * day to train based on recovery state from [RecoveryReadinessEngine]. + */ +class WorkoutSuggestionEngine { + + // keyword → region mapping (lowercase keywords for case-insensitive matching). + // Order matters: checked top-to-bottom via firstOrNull. + // Shoulders is placed before Pull so "lateral raise" matches "lateral raise" before "lat". + private val regionKeywords: Map> = mapOf( + "Push" to listOf("bench", "press", "dip", "push", "fly", "flye", "chest", "tricep"), + "Shoulders" to listOf("lateral raise", "front raise", "face pull", "upright row", "shoulder", "overhead", "ohp"), + "Pull" to listOf("row", "pull", "lat", "deadlift", "shrug", "bicep", "chin"), + "Legs" to listOf("squat", "leg", "lunge", "calf", "glute", "hip thrust", "rdl", "hamstring", "quad"), + "Core" to listOf("plank", "crunch", "ab", "oblique", "core", "sit-up", "situp", "hollow"), + "Arms" to listOf("curl", "extension", "forearm", "wrist"), + ) + + /** + * Returns the training region for [exerciseName] using keyword matching, + * or null if no region matches. + */ + fun regionForExercise(exerciseName: String): String? { + val lower = exerciseName.lowercase() + return regionKeywords.entries.firstOrNull { (_, keywords) -> + keywords.any { kw -> lower.contains(kw) } + }?.key + } + + /** + * Scores a plan day against [readiness] (0.0 = fully fatigued, 1.0 = fully recovered). + * Returns 0.5 (neutral) if none of the exercises map to a known region. + */ + fun scoreDay(readiness: Map, exerciseNames: List): Double { + val scores = exerciseNames + .mapNotNull { name -> regionForExercise(name)?.let { region -> readiness[region] } } + return if (scores.isEmpty()) 0.5 else scores.average() + } + + /** + * Returns the 0-based index of the plan day with the highest readiness score. + * Ties are broken by lower index (stable). + */ + fun suggestDayIndex( + readiness: Map, + dayExerciseNames: List>, + ): Int { + if (dayExerciseNames.isEmpty()) return 0 + return dayExerciseNames + .mapIndexed { i, exercises -> i to scoreDay(readiness, exercises) } + .maxByOrNull { (_, score) -> score } + ?.first ?: 0 + } + + /** + * Returns a short human-readable recommendation sentence for the UI. + */ + fun recommendationBlurb(readiness: Map, dayName: String): String { + val topRegion = readiness.maxByOrNull { it.value } + val freshness = when { + (topRegion?.value ?: 0.5) >= 0.8 -> "Your muscles are fresh" + (topRegion?.value ?: 0.5) >= 0.5 -> "You're recovering well" + else -> "Take it easy today" + } + return "$freshness — $dayName looks like your best pick." + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/milestone/MilestoneService.kt b/app/src/main/java/com/ironlog/app/domain/milestone/MilestoneService.kt new file mode 100644 index 0000000..ce3ad01 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/milestone/MilestoneService.kt @@ -0,0 +1,36 @@ +package com.ironlog.app.domain.milestone + +import com.ironlog.app.data.repository.SettingsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray + +class MilestoneService( + private val settingsRepository: SettingsRepository = SettingsRepository(), +) { + suspend fun registerPostWorkout(totalVolumeKg: Double, streakDays: Int, newPr: Boolean) = withContext(Dispatchers.IO) { + val unlocked = load().toMutableSet() + if (newPr) unlocked += "MILESTONE_PR" + if (totalVolumeKg >= 1000) unlocked += "MILESTONE_LIFT_1T" + if (totalVolumeKg >= 5000) unlocked += "MILESTONE_LIFT_5T" + if (totalVolumeKg >= 10000) unlocked += "MILESTONE_LIFT_10T" + if (streakDays >= 7) unlocked += "MILESTONE_STREAK_7" + if (streakDays >= 30) unlocked += "MILESTONE_STREAK_30" + if (streakDays >= 100) unlocked += "MILESTONE_STREAK_100" + save(unlocked.toList().sorted()) + } + + suspend fun load(): Set = withContext(Dispatchers.IO) { + val raw = settingsRepository.getString("milestone_unlocks") ?: return@withContext emptySet() + runCatching { + val arr = JSONArray(raw) + buildSet { + for (i in 0 until arr.length()) add(arr.optString(i)) + }.filter { it.isNotBlank() }.toSet() + }.getOrDefault(emptySet()) + } + + private suspend fun save(values: List) { + settingsRepository.setString("milestone_unlocks", JSONArray(values).toString(), "json") + } +} diff --git a/app/src/main/java/com/ironlog/app/domain/sharing/PlanQrCodec.kt b/app/src/main/java/com/ironlog/app/domain/sharing/PlanQrCodec.kt new file mode 100644 index 0000000..9c34ce7 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/domain/sharing/PlanQrCodec.kt @@ -0,0 +1,110 @@ +package com.ironlog.app.domain.sharing + +import android.graphics.Bitmap +import com.ironlog.app.ui.model.UiPlan +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import qrcode.QRCode +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.Base64 +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +/** + * Converts a [UiPlan] to a compact Base64-encoded payload suitable for QR codes, + * and back again. + * + * Pipeline: UiPlan → JSON → GZIP → Base64 → QR + * Reverse: QR text → Base64 → GZIP decompress → JSON → UiPlan + */ +class PlanQrCodec { + + companion object { + private const val MAX_PAYLOAD_CHARS = 8_192 + private const val MAX_COMPRESSED_BYTES = 64 * 1024 + private const val MAX_DECOMPRESSED_BYTES = 512 * 1024 + private const val MAX_PLAN_DAYS = 31 + private const val MAX_EXERCISES = 500 + } + + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + /** + * Encode [plan] to a QR-embeddable string. + * Returns Base64(GZIP(JSON)) of the plan. + */ + fun encodeToPayload(plan: UiPlan): String { + val jsonString = json.encodeToString(plan) + val compressed = gzip(jsonString.toByteArray(Charsets.UTF_8)) + return Base64.getUrlEncoder().withoutPadding().encodeToString(compressed) + } + + /** + * Decode a [payload] string back to a [UiPlan]. + * Returns null if the payload is malformed or cannot be decoded. + */ + fun decodeFromPayload(payload: String): UiPlan? { + if (payload.isBlank() || payload.length > MAX_PAYLOAD_CHARS) return null + return runCatching { + val compressed = Base64.getUrlDecoder().decode(payload) + require(compressed.size <= MAX_COMPRESSED_BYTES) + val jsonBytes = gunzip(compressed) + json.decodeFromString(String(jsonBytes, Charsets.UTF_8)) + .takeIf(::isStructurallySafe) + }.getOrNull() + } + + /** + * Generate a [Bitmap] QR code from [payload]. + * Returns null if payload is too large for a QR code. + */ + fun generateQrBitmap(payload: String, sizePx: Int = 800): Bitmap? { + return runCatching { + val qrCode = QRCode.ofSquares() + .build(payload) + val rendered = qrCode.render() + // qrcode-kotlin-android returns an Android Bitmap directly via nativeImage() + val nativeResult = rendered.nativeImage() + val sourceBitmap = nativeResult as Bitmap + Bitmap.createScaledBitmap(sourceBitmap, sizePx, sizePx, false) + }.getOrNull() + } + + // ── Compression helpers ─────────────────────────────────────────────── + + private fun gzip(data: ByteArray): ByteArray { + val bos = ByteArrayOutputStream() + GZIPOutputStream(bos).use { it.write(data) } + return bos.toByteArray() + } + + private fun gunzip(data: ByteArray): ByteArray { + val bis = ByteArrayInputStream(data) + return GZIPInputStream(bis).use { input -> + val output = ByteArrayOutputStream() + val buffer = ByteArray(8 * 1024) + while (true) { + val count = input.read(buffer) + if (count < 0) break + require(output.size() + count <= MAX_DECOMPRESSED_BYTES) { "Plan payload is too large" } + output.write(buffer, 0, count) + } + output.toByteArray() + } + } + + private fun isStructurallySafe(plan: UiPlan): Boolean { + if (plan.name.length > 200 || plan.description.length > 10_000) return false + if (plan.days.size > MAX_PLAN_DAYS) return false + if (plan.days.sumOf { it.exercises.size } > MAX_EXERCISES) return false + return plan.days.all { day -> + day.name.length <= 200 && day.exercises.all { exercise -> + exercise.name.length <= 300 && + exercise.notes.length <= 5_000 && + exercise.sets in 1..100 && + exercise.restSeconds in 0..86_400 + } + } + } +} diff --git a/app/src/main/java/com/ironlog/app/navigation/AppNavigator.kt b/app/src/main/java/com/ironlog/app/navigation/AppNavigator.kt new file mode 100644 index 0000000..d454f98 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/navigation/AppNavigator.kt @@ -0,0 +1,1009 @@ +package com.ironlog.app.navigation + +import android.net.Uri +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Assignment +import androidx.compose.material.icons.automirrored.outlined.ShowChart +import androidx.compose.material.icons.outlined.ChevronRight +import androidx.compose.material.icons.outlined.FitnessCenter +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.dialog +import androidx.navigation.compose.rememberNavController +import com.ironlog.app.data.objectbox.AthleteCalibrationEntity +import com.ironlog.app.data.objectbox.AthleteCalibrationEntity_ +import com.ironlog.app.data.repository.BodyMeasurementRepository +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.screens.workout.ActiveWorkoutScreen +import com.ironlog.app.ui.screens.plans.AIPlanScreen +import com.ironlog.app.ui.screens.settings.BackupCenterScreen +import com.ironlog.app.ui.screens.body.BodyMeasurementsScreen +import com.ironlog.app.ui.screens.body.BodyWeightScreen +import com.ironlog.app.ui.screens.settings.CreateExerciseScreen +import com.ironlog.app.ui.screens.settings.DataPortabilityScreen +import com.ironlog.app.ui.screens.settings.ExerciseLibraryScreen +import com.ironlog.app.ui.screens.stats.ExerciseProgressScreen +import com.ironlog.app.ui.screens.settings.GymProfileEditorScreen +import com.ironlog.app.ui.screens.settings.GymProfilesScreen +import com.ironlog.app.ui.screens.stats.HistoryScreen +import com.ironlog.app.ui.screens.home.HomeScreen +import com.ironlog.app.ui.screens.settings.ImportCenterScreen +import com.ironlog.app.ui.screens.recovery.RecoveryCircuitSheet +import com.ironlog.app.ui.screens.gamification.StatusWindowScreen +import com.ironlog.app.ui.theme.IronLogThemeTokens +import com.ironlog.app.ui.screens.OnboardingScreen +import com.ironlog.app.ui.screens.plans.PlanEditorScreen +import com.ironlog.app.ui.screens.plans.PlansScreen +import com.ironlog.app.ui.screens.plans.PlanQrScanScreen +import com.ironlog.app.ui.screens.settings.PrivacyScreen +import com.ironlog.app.ui.screens.plans.ProgramInsightsScreen +import com.ironlog.app.ui.screens.plans.ProgramPickerScreen +import com.ironlog.app.ui.screens.body.ProgressPhotosScreen +import com.ironlog.app.ui.screens.recovery.RecoveryMapScreen +import com.ironlog.app.ui.screens.settings.RestoreDataScreen +import com.ironlog.app.ui.screens.settings.SettingsScreen +import com.ironlog.app.ui.screens.stats.StatsScreen +import com.ironlog.app.ui.screens.intelligence.TrainingIntelligenceScreen +import com.ironlog.app.ui.screens.stats.VolumeAnalyticsScreen +import com.ironlog.app.ui.screens.workout.WorkoutCalendarScreen +import android.app.Application +import android.content.Context +import androidx.compose.ui.platform.LocalContext +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.domain.intelligence.CloudAiKeyStore +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.viewmodel.GamificationViewModel +import com.ironlog.app.ui.viewmodel.GamificationViewModelFactory +import com.ironlog.app.ui.viewmodel.BodyMeasurementsViewModel +import com.ironlog.app.ui.viewmodel.BodyMeasurementsViewModelFactory +import com.ironlog.app.ui.viewmodel.AppDataViewModel +import com.ironlog.app.ui.viewmodel.PlansViewModel +import com.ironlog.app.ui.viewmodel.StatsViewModel +import com.ironlog.app.widget.WidgetUpdateWorker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private val TabScreens = listOf( + TabSpec("Home", Icons.Outlined.Home, null), + TabSpec("Plans", Icons.Outlined.FitnessCenter, "PLANS"), + TabSpec("Log", Icons.AutoMirrored.Outlined.Assignment, "LOG"), + TabSpec("Stats", Icons.AutoMirrored.Outlined.ShowChart, "STATS"), + TabSpec("Settings", Icons.Outlined.Settings, "SETTINGS"), +) + +private val StackScreens = listOf( + StackSpec("ActiveWorkout", "ACTIVE WORKOUT", transparentModal = true, gesturesEnabled = false), + StackSpec("PlanEditor", "EDIT PLAN"), + StackSpec("ExerciseLibrary", "EXERCISE LIBRARY"), + StackSpec("BodyWeight", "BODY WEIGHT"), + StackSpec("CreateExercise", "CREATE EXERCISE"), + StackSpec("ProgressPhotos", "PROGRESS PHOTOS"), + StackSpec("ProgramPicker", "PROGRAMS"), + StackSpec("ExerciseProgress/{exerciseName}", "PROGRESS"), + StackSpec("WorkoutCalendar", "CALENDAR"), + StackSpec("VolumeAnalytics", "VOLUME ANALYTICS"), + StackSpec("RecoveryMap", "MUSCLE RECOVERY"), + StackSpec("ProgramInsights", "PROGRAM INSIGHTS"), + StackSpec("BodyMeasurements", "BODY TRACKER"), + StackSpec("GymProfiles", "GYM PROFILES"), + StackSpec("GymProfileEditor", "EDIT PROFILE"), + StackSpec("BackupCenter", "BACKUP CENTER"), + StackSpec("Privacy", "PRIVACY"), + StackSpec("RestoreData", "RESTORE DATA"), + StackSpec("ImportCenter", "IMPORT CENTER"), + StackSpec("AIPlan", "AI PLAN CREATOR"), + StackSpec("TrainingIntelligence", "ATHLETE PROFILE"), + StackSpec("DataPortability", "BACKUP & EXPORT"), +) + +data class TabSpec(val name: String, val icon: ImageVector, val title: String?) +data class StackSpec(val route: String, val title: String, val transparentModal: Boolean = false, val gesturesEnabled: Boolean = true) + +/** + * Compose Navigation translation of AppNavigator.js. + * React Navigation's stack + PagerView tab setup becomes NavHost + HorizontalPager. + */ +@Composable +fun AppNavigator( + navController: NavHostController = rememberNavController(), + onboardingComplete: Boolean = true, +) { + val colors = useTheme() + val bodyVm: BodyMeasurementsViewModel = + viewModel(factory = BodyMeasurementsViewModelFactory(BodyMeasurementRepository())) + val statsVm: StatsViewModel = viewModel() + NavHost( + navController = navController, + startDestination = if (onboardingComplete) "Tabs" else "Onboarding", + modifier = Modifier.fillMaxSize().background(colors.bg), + ) { + composable("Onboarding") { + val context = LocalContext.current + OnboardingScreen( + onComplete = { draft -> + settingsRepoSaveOnboardingDataFull(context, draft) + navController.navigate("Tabs") { + popUpTo("Onboarding") { inclusive = true } + } + } + ) + } + composable("Tabs") { Tabs(navController) } + // Active workout with optional dayId arg + dialog( + route = "ActiveWorkout/{dayId}", + dialogProperties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + dismissOnBackPress = false, + ), + ) { backStack -> + val tabsEntry = remember(navController) { + runCatching { navController.getBackStackEntry("Tabs") }.getOrNull() + } + val tabsAppVm: AppDataViewModel? = tabsEntry?.let { viewModel(it) } + ActiveWorkoutScreen( + dayId = Uri.decode(backStack.arguments?.getString("dayId").orEmpty()), + onFinish = { tabsAppVm?.refresh(); navController.popBackStack() }, + onMinimize = { navController.popBackStack() }, + ) + } + dialog( + route = "ActiveWorkout", + dialogProperties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + dismissOnBackPress = false, + ), + ) { + // Pass empty dayId — the ViewModel reads active_workout_day_id from SettingsRepository + // during initWorkout and resumes the persisted workout. The old LaunchedEffect approach + // caused a render-before-read race where initWorkout(dayId="") was called first, + // which the VM handles correctly via persistedActiveId lookup. + val tabsEntry = remember(navController) { + runCatching { navController.getBackStackEntry("Tabs") }.getOrNull() + } + val tabsAppVm: AppDataViewModel? = tabsEntry?.let { viewModel(it) } + ActiveWorkoutScreen( + dayId = "", + onFinish = { tabsAppVm?.refresh(); navController.popBackStack() }, + onMinimize = { navController.popBackStack() }, + ) + } + composable("PlanEditor/{planId}") { backStack -> + PlanEditorScreen( + planId = Uri.decode(backStack.arguments?.getString("planId").orEmpty()), + onBack = { navController.popBackStack() }, + onStartWorkout = { dayId -> + navController.navigate(if (dayId.isNotBlank()) "ActiveWorkout/${Uri.encode(dayId)}" else "ActiveWorkout") + }, + ) + } + composable("PlanEditor") { + PlanEditorScreen( + planId = "", + onBack = { navController.popBackStack() }, + onStartWorkout = { dayId -> + navController.navigate(if (dayId.isNotBlank()) "ActiveWorkout/${Uri.encode(dayId)}" else "ActiveWorkout") + }, + ) + } + composable("ExerciseLibrary") { + val statsState by statsVm.state.collectAsState() + ExerciseLibraryScreen( + history = statsState.history, + onBack = { navController.popBackStack() }, + onCreateExercise = { seed -> navController.navigate("CreateExercise/${Uri.encode(seed)}") }, + onExerciseClick = { ex -> navController.navigate("ExerciseProgress/${Uri.encode(ex.name)}") }, + onOpenExerciseProgress = { name -> navController.navigate("ExerciseProgress/${Uri.encode(name)}") }, + ) + } + composable("CreateExercise/{seedName}") { backStack -> + CreateExerciseScreen( + initialName = Uri.decode(backStack.arguments?.getString("seedName").orEmpty()), + onSaved = { navController.popBackStack() }, + onBack = { navController.popBackStack() }, + ) + } + composable("CreateExercise") { + CreateExerciseScreen( + onSaved = { navController.popBackStack() }, + onBack = { navController.popBackStack() }, + ) + } + composable("Privacy") { PrivacyScreen(onBack = { navController.popBackStack() }) } + composable("ExerciseProgress/{exerciseName}") { backStack -> + ExerciseProgressScreen( + exerciseName = Uri.decode(backStack.arguments?.getString("exerciseName").orEmpty()), + onBack = { navController.popBackStack() }, + ) + } + composable("BodyWeight") { + val bodyState by bodyVm.state.collectAsState() + val statsState by statsVm.state.collectAsState() + val bwSettingsRepo = remember { SettingsRepository() } + var goalWeight by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + goalWeight = bwSettingsRepo.getSettingNumber("bodyweight_goal") + } + BodyWeightScreen( + bodyWeight = bodyState.bodyWeight, + weightUnit = statsState.weightUnit, + goalWeight = goalWeight, + onBack = { navController.popBackStack() }, + onLogBodyWeight = { bodyVm.add(it) }, + onDeleteBodyWeightEntry = { bodyVm.remove(it) }, + onSetGoalWeight = { g -> + goalWeight = g + if (g != null) bwSettingsRepo.setSetting("bodyweight_goal", g, "number") + else bwSettingsRepo.setSetting("bodyweight_goal", null, "number") + }, + ) + } + composable("WorkoutCalendar") { + val statsState by statsVm.state.collectAsState() + val calScope = rememberCoroutineScope() + val calWorkoutRepo = remember { com.ironlog.app.data.repository.WorkoutRepository() } + val calSettingsRepo = remember { SettingsRepository() } + val calApp = LocalContext.current.applicationContext + WorkoutCalendarScreen( + history = statsState.history, + weightUnit = statsState.weightUnit, + onBack = { navController.popBackStack() }, + onLogWorkout = { input -> + calScope.launch { + runCatching { calWorkoutRepo.createCompletedWorkout(input) } + .onSuccess { WidgetUpdateWorker.enqueueOneTime(calApp) } + } + }, + onStartWorkout = { dateKey -> + calScope.launch(Dispatchers.IO) { + calSettingsRepo.setString("active_workout_intended_date", dateKey) + withContext(Dispatchers.Main) { navController.navigate("ActiveWorkout") } + } + }, + ) + } + composable("BodyMeasurements") { + val bodyState by bodyVm.state.collectAsState() + val statsState by statsVm.state.collectAsState() + BodyMeasurementsScreen( + measurements = bodyState.measurements, + bodyWeight = bodyState.bodyWeight, + weightUnit = statsState.weightUnit, + onLogBodyWeight = { bodyVm.add(it) }, + onAddMeasurement = { bodyVm.add(it) }, + ) + } + composable("VolumeAnalytics") { + VolumeAnalyticsScreen( + onBack = { navController.popBackStack() }, + onOpenBodyWeight = { navController.navigate("BodyWeight") }, + ) + } + composable("RecoveryMap") { + RecoveryMapScreen( + onBack = { navController.popBackStack() }, + onOpenVolumeAnalytics = { navController.navigate("VolumeAnalytics") }, + ) + } + composable("TrainingIntelligence") { + TrainingIntelligenceScreen( + onBack = { navController.popBackStack() }, + onStartWorkout = { dayId -> + navController.navigate(if (!dayId.isNullOrBlank()) "ActiveWorkout/${Uri.encode(dayId)}" else "ActiveWorkout") + }, + onOpenRecoveryMap = { navController.navigate("RecoveryMap") }, + onOpenAIPlan = { navController.navigate("AIPlan") }, + onOpenProgramInsights = { navController.navigate("ProgramInsights") }, + ) + } + composable("ProgramPicker") { ProgramPickerScreen(onBack = { navController.popBackStack() }) } + composable("ProgramInsights") { + val piScope = rememberCoroutineScope() + ProgramInsightsScreen( + onBack = { navController.popBackStack() }, + // Set pending_nav_route then pop back — the Tabs polling loop scrolls to Plans. + // Avoids the phantom "Plans" composable which created duplicate Tabs back-stack entries. + onOpenPlans = { + piScope.launch { settingsRepoSetPendingNavRoute("Plans") } + navController.popBackStack() + }, + ) + } + composable("ProgressPhotos") { ProgressPhotosScreen(onBack = { navController.popBackStack() }) } + composable("GymProfiles") { + GymProfilesScreen( + onBack = { navController.popBackStack() }, + onCreate = { navController.navigate("GymProfileEditor") }, + onEdit = { id -> navController.navigate("GymProfileEditor/${Uri.encode(id)}") }, + ) + } + composable("GymProfileEditor/{profileId}") { backStack -> + GymProfileEditorScreen( + profileId = Uri.decode(backStack.arguments?.getString("profileId").orEmpty()), + onSaved = { navController.popBackStack() }, + onBack = { navController.popBackStack() }, + ) + } + composable("GymProfileEditor") { + GymProfileEditorScreen( + onSaved = { navController.popBackStack() }, + onBack = { navController.popBackStack() }, + ) + } + composable("BackupCenter") { + BackupCenterScreen( + onBack = { navController.popBackStack() }, + onOpenDataPortability = { navController.navigate("DataPortability") }, + onOpenPrivacy = { navController.navigate("Privacy") }, + ) + } + composable("RestoreData") { + RestoreDataScreen( + onBack = { navController.popBackStack() }, + onOpenBackupCenter = { navController.navigate("BackupCenter") }, + onOpenImportCenter = { navController.navigate("ImportCenter") }, + onOpenDataPortability = { source -> navController.navigate("DataPortability/${Uri.encode(source)}") }, + ) + } + composable("ImportCenter") { + val app = LocalContext.current.applicationContext as Application + val icScope = rememberCoroutineScope() + val appVm: AppDataViewModel = viewModel() + val statsVm: StatsViewModel = viewModel() + val gamificationVm: GamificationViewModel = viewModel( + factory = GamificationViewModelFactory(app, ObjectBox.store) + ) + ImportCenterScreen( + onBack = { navController.popBackStack() }, + onOpenDataPortability = { source -> navController.navigate("DataPortability/${Uri.encode(source)}") }, + onRestoreComplete = { + icScope.launch { + appVm.refresh().join() + statsVm.refresh().join() + gamificationVm.refreshFromHistory( + statsVm.state.value.history, + appVm.state.value.settings.weeklyGoalDays, + ) + WidgetUpdateWorker.enqueueOneTime(app) + } + }, + ) + } + composable("AIPlan") { AIPlanScreen(onBack = { navController.popBackStack() }) } + composable("ScanPlanQr") { + val plansVm: PlansViewModel = viewModel() + PlanQrScanScreen( + onPlanScanned = { plan -> + plansVm.importPlanFromQr(plan) + navController.popBackStack() + }, + onBack = { navController.popBackStack() }, + ) + } + composable("DataPortability") { + val app = LocalContext.current.applicationContext as Application + val dpScope = rememberCoroutineScope() + val appVm: AppDataViewModel = viewModel() + val statsVm: StatsViewModel = viewModel() + val gamificationVm: GamificationViewModel = viewModel( + factory = GamificationViewModelFactory(app, ObjectBox.store) + ) + DataPortabilityScreen( + onBack = { navController.popBackStack() }, + onRestoreComplete = { + dpScope.launch { + appVm.refresh().join() + statsVm.refresh().join() + gamificationVm.refreshFromHistory( + statsVm.state.value.history, + appVm.state.value.settings.weeklyGoalDays, + ) + WidgetUpdateWorker.enqueueOneTime(app) + } + }, + ) + } + composable("DataPortability/{source}") { backStack -> + val app = LocalContext.current.applicationContext as Application + val dpScope = rememberCoroutineScope() + val appVm: AppDataViewModel = viewModel() + val statsVm: StatsViewModel = viewModel() + val gamificationVm: GamificationViewModel = viewModel( + factory = GamificationViewModelFactory(app, ObjectBox.store) + ) + DataPortabilityScreen( + sourceHint = Uri.decode(backStack.arguments?.getString("source").orEmpty()), + onBack = { navController.popBackStack() }, + onRestoreComplete = { + dpScope.launch { + appVm.refresh().join() + statsVm.refresh().join() + gamificationVm.refreshFromHistory( + statsVm.state.value.history, + appVm.state.value.settings.weeklyGoalDays, + ) + WidgetUpdateWorker.enqueueOneTime(app) + } + }, + ) + } + composable("statusWindow") { + val app = LocalContext.current.applicationContext as Application + val tabsEntry = remember(navController) { + runCatching { navController.getBackStackEntry("Tabs") }.getOrNull() + } + val appVm: AppDataViewModel = tabsEntry?.let { viewModel(it) } ?: viewModel() + val appState by appVm.state.collectAsState() + val gamificationVm: GamificationViewModel = viewModel( + factory = GamificationViewModelFactory(app, ObjectBox.store) + ) + val gamState by gamificationVm.uiState.collectAsState() + var showMakeupSheet by remember { mutableStateOf(false) } + LaunchedEffect(appState.history, appState.settings.weeklyGoalDays) { + gamificationVm.refreshFromHistory(appState.history, appState.settings.weeklyGoalDays) + } + + StatusWindowScreen( + state = gamState, + onBack = { navController.popBackStack() }, + onRecoveryCircuitTap = { showMakeupSheet = true }, + ) + + if (showMakeupSheet) { + RecoveryCircuitSheet( + onDismiss = { showMakeupSheet = false }, + onComplete = { _ -> + showMakeupSheet = false + val weekKey = java.time.LocalDate.now().let { + val week = it.get(java.time.temporal.WeekFields.ISO.weekOfWeekBasedYear()) + val year = it.get(java.time.temporal.WeekFields.ISO.weekBasedYear()) + "$year-W${week.toString().padStart(2, '0')}" + } + gamificationVm.completeRecoveryCircuit(weekKey) + gamificationVm.refreshFromHistory(appState.history, appState.settings.weeklyGoalDays) + }, + ) + } + } + } +} + +private suspend fun settingsRepoSaveOnboardingDataFull(context: Context, draft: com.ironlog.app.ui.screens.onboarding.OnboardingDraft) { + val repo = SettingsRepository() + repo.setBoolean("onboarding_complete", true) + val raw = repo.getString("ironlog_settings") ?: "{}" + val json = runCatching { org.json.JSONObject(raw) }.getOrDefault(org.json.JSONObject()) + json.put("weeklyGoalDays", draft.weeklyGoalDays.coerceIn(1, 7)) + json.put("weightUnit", if (draft.weightUnit == "lbs") "lbs" else "kg") + json.put("progressionStyle", draft.progressionStyle) + json.put("goalMode", draft.goalMode) + json.put("intelligenceMode", draft.intelligenceMode) + json.put("cloudAiModelName", draft.cloudAiModelName) + json.put("cloudAiProviderPreset",draft.cloudAiProviderPreset) + // Resolve base URL, API format and display name from the provider enum so they + // are available in Settings immediately after onboarding — without this the + // cloud AI section shows a blank URL and cloudConfigured stays false. + val onboardingProvider = com.ironlog.app.ui.screens.onboarding.OnboardingConfig.providerFor(draft.cloudAiProviderPreset) + json.put("cloudAiBaseUrl", onboardingProvider.baseUrl) + json.put("cloudAiApiFormat", onboardingProvider.apiFormat) + json.put("cloudAiDisplayName", onboardingProvider.displayName) + if (draft.userName.isNotBlank()) json.put("userName", draft.userName) + repo.setString("ironlog_settings", json.toString(), "json") + onboardingBaselineSettingsFromDraft(draft).forEach { (key, value) -> + if (value == "true" || value == "false") { + repo.setBoolean(key, value.toBoolean()) + } else { + repo.setString(key, value) + } + } + val calibrationBox = ObjectBox.store.boxFor(AthleteCalibrationEntity::class.java) + val existing = calibrationBox.query(AthleteCalibrationEntity_.offlineUserId.equal("local")) + .build().use { it.findFirst() } + calibrationBox.put(buildCalibrationEntityFromOnboardingDraft(draft, existing)) + if (draft.cloudAiApiKey.isNotBlank()) { + val provider = draft.cloudAiProviderPreset.ifBlank { "custom" } + CloudAiKeyStore.save(context, provider, draft.cloudAiApiKey) + } +} + +internal fun buildCalibrationEntityFromOnboardingDraft( + draft: com.ironlog.app.ui.screens.onboarding.OnboardingDraft, + existing: AthleteCalibrationEntity? = null, + updatedAtMs: Long = System.currentTimeMillis(), +): AthleteCalibrationEntity { + val entity = existing ?: AthleteCalibrationEntity() + entity.offlineUserId = "local" + entity.trainingAgeMonths = draft.trainingAgeMonths.coerceAtLeast(0) + entity.historicalTrainingDaysPerWeek = draft.historicalTrainingDaysPerWeek.coerceIn(1, 7) + entity.weightUnit = if (draft.weightUnit == "lbs") "lbs" else "kg" + entity.bodyweightKg = draft.bodyweightKg.takeIf { it > 0 }?.toDouble() + entity.goalMode = draft.goalMode.lowercase() + entity.weeklyGoalDays = draft.weeklyGoalDays.coerceIn(1, 7) + entity.hasPastTraining = draft.hasPastTraining + entity.hasGymAccess = draft.hasGymAccess + entity.baselinePushups = draft.baselinePushups.coerceAtLeast(0) + entity.baselinePullups = draft.baselinePullups.coerceAtLeast(0) + entity.baselineBenchKg = draft.baselineBenchKg.coerceAtLeast(0) + entity.baselineLatPulldownKg = draft.baselineLatPulldownKg.coerceAtLeast(0) + entity.baselineMileRunSeconds = draft.baselineMileRunSeconds.coerceAtLeast(0) + entity.updatedAt = updatedAtMs + return entity +} + +internal fun onboardingBaselineSettingsFromDraft( + draft: com.ironlog.app.ui.screens.onboarding.OnboardingDraft, +): Map = linkedMapOf( + "baseline_training_age_months" to draft.trainingAgeMonths.coerceAtLeast(0).toString(), + "baseline_historical_training_days_per_week" to draft.historicalTrainingDaysPerWeek.coerceIn(1, 7).toString(), + "baseline_bodyweight_kg" to draft.bodyweightKg.coerceAtLeast(0).toString(), + "baseline_pushups" to draft.baselinePushups.coerceAtLeast(0).toString(), + "baseline_pullups" to draft.baselinePullups.coerceAtLeast(0).toString(), + "baseline_bench_kg" to draft.baselineBenchKg.coerceAtLeast(0).toString(), + "baseline_lat_pulldown_kg" to draft.baselineLatPulldownKg.coerceAtLeast(0).toString(), + "baseline_mile_run_seconds" to draft.baselineMileRunSeconds.coerceAtLeast(0).toString(), + "baseline_has_past_training" to draft.hasPastTraining.toString(), + "baseline_has_gym_access" to draft.hasGymAccess.toString(), +) + +private suspend fun settingsRepoSetPendingNavRoute(route: String) { + SettingsRepository().setString("pending_nav_route", route) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tab host +// ───────────────────────────────────────────────────────────────────────────── + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Tabs(navController: NavHostController) { + val colors = useTheme() + val settingsRepo = remember { SettingsRepository() } + val workoutRepo = remember { com.ironlog.app.data.repository.WorkoutRepository() } + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState(initialPage = 0, pageCount = { TabScreens.size }) + + + + // selectedTabIndex updates immediately on tap for a snappy indicator; + // pagerState.currentPage syncs it back when the swipe gesture settles. + var selectedTabIndex by remember { mutableIntStateOf(0) } + var activeWorkoutId by remember { mutableStateOf(null) } + var activeWorkoutDayId by remember { mutableStateOf(null) } + var activeWorkoutDayName by remember { mutableStateOf(null) } + var activeWorkoutStartMs by remember { mutableLongStateOf(0L) } + + // Sync indicator when user swipes the pager + LaunchedEffect(pagerState.currentPage) { + selectedTabIndex = pagerState.currentPage + } + + // Polling loop — active workout + pending deep-links (1.5 s cadence) + LaunchedEffect(Unit) { + while (true) { + data class TabsPollResult( + val workoutId: String?, + val dayId: String?, + val dayName: String?, + val startMsStr: String?, + val pendingRoute: String, + ) + val result = withContext(Dispatchers.IO) { + TabsPollResult( + workoutId = settingsRepo.getActiveWorkoutId(), + dayId = settingsRepo.getString("active_workout_day_id"), + dayName = settingsRepo.getString("active_workout_day_name"), + startMsStr = settingsRepo.getString("active_workout_start_ms"), + pendingRoute = settingsRepo.getString("pending_nav_route").orEmpty(), + ) + } + activeWorkoutId = result.workoutId + activeWorkoutDayId = result.dayId + activeWorkoutDayName = result.dayName + if (!result.startMsStr.isNullOrBlank()) { + val parsed = result.startMsStr.toLongOrNull() ?: 0L + if (parsed != activeWorkoutStartMs) activeWorkoutStartMs = parsed + } else { + activeWorkoutStartMs = 0L + } + if (result.pendingRoute.isNotBlank()) { + // Programmatic deep-link navigation — instant, no slide animation + when (result.pendingRoute) { + "Home" -> { selectedTabIndex = 0; pagerState.scrollToPage(0) } + "Plans" -> { selectedTabIndex = 1; pagerState.scrollToPage(1) } + "Log" -> { selectedTabIndex = 2; pagerState.scrollToPage(2) } + "Stats" -> { selectedTabIndex = 3; pagerState.scrollToPage(3) } + "Settings" -> { selectedTabIndex = 4; pagerState.scrollToPage(4) } + else -> navController.navigate(result.pendingRoute) + } + withContext(Dispatchers.IO) { settingsRepo.removeSetting("pending_nav_route") } + } + delay(1500) + } + } + + val hazeState = rememberHazeState() + + Box(Modifier.fillMaxSize().background(colors.bg)) { + + // Pager — hazeSource records pixels for the tab bar blur. + // userScrollEnabled = true lets users swipe; selectedTabIndex syncs via + // LaunchedEffect(pagerState.currentPage) above. + Box( + Modifier + .fillMaxSize() + .hazeSource(hazeState), + ) { + HorizontalPager( + state = pagerState, + userScrollEnabled = true, + beyondViewportPageCount = 1, + ) { page -> + when (TabScreens[page].name) { + "Home" -> HomeScreen( + onStartWorkout = { _, dayId -> + navController.navigate(if (dayId.isNotBlank()) "ActiveWorkout/${Uri.encode(dayId)}" else "ActiveWorkout") + }, + onOpenBodyWeight = { navController.navigate("BodyWeight") }, + onOpenRecovery = { navController.navigate("RecoveryMap") }, + onOpenTrainingIntelligence = { navController.navigate("TrainingIntelligence") }, + onOpenProgramPicker = { navController.navigate("ProgramPicker") }, + onOpenProgramInsights = { navController.navigate("ProgramInsights") }, + onOpenVolumeAnalytics = { navController.navigate("VolumeAnalytics") }, + onResumeWorkout = { + navController.navigate( + if (!activeWorkoutDayId.isNullOrBlank()) "ActiveWorkout/${Uri.encode(activeWorkoutDayId)}" else "ActiveWorkout" + ) + }, + onOpenStatusWindow = { navController.navigate("statusWindow") }, + onDiscardWorkout = { + val idToAbandon = activeWorkoutId + scope.launch(Dispatchers.IO) { + if (!idToAbandon.isNullOrBlank()) { + runCatching { workoutRepo.abandonWorkout(idToAbandon) } + } + settingsRepo.removeSetting("active_workout_id") + settingsRepo.removeSetting("active_workout_day_id") + settingsRepo.removeSetting("active_workout_day_name") + settingsRepo.removeSetting("active_workout_start_ms") + withContext(Dispatchers.Main) { + activeWorkoutId = null + activeWorkoutDayId = null + activeWorkoutDayName = null + activeWorkoutStartMs = 0L + } + } + }, + ) + "Plans" -> PlansScreen( + onOpenPlan = { planId -> navController.navigate("PlanEditor/${Uri.encode(planId)}") }, + onStartWorkout = { _, dayId -> + navController.navigate(if (dayId.isNotBlank()) "ActiveWorkout/${Uri.encode(dayId)}" else "ActiveWorkout") + }, + onOpenProgramPicker = { navController.navigate("ProgramPicker") }, + onOpenAIPlan = { navController.navigate("AIPlan") }, + onOpenQrScan = { navController.navigate("ScanPlanQr") }, + ) + "Log" -> HistoryScreen( + onOpenProgressPhotos = { navController.navigate("ProgressPhotos") }, + onGoHome = { + selectedTabIndex = 0 + scope.launch { pagerState.animateScrollToPage(0) } + }, + ) + "Stats" -> StatsScreen( + onOpenExerciseProgress = { exerciseName -> navController.navigate("ExerciseProgress/${Uri.encode(exerciseName)}") }, + onOpenCalendar = { navController.navigate("WorkoutCalendar") }, + onOpenBodyTracker = { navController.navigate("BodyMeasurements") }, + onOpenVolumeAnalytics = { navController.navigate("VolumeAnalytics") }, + onOpenRecoveryMap = { navController.navigate("RecoveryMap") }, + onOpenTrainingIntelligence = { navController.navigate("TrainingIntelligence") }, + onOpenProgressPhotos = { navController.navigate("ProgressPhotos") }, + ) + "Settings" -> SettingsScreen( + onOpenProgramPicker = { navController.navigate("ProgramPicker") }, + onOpenAIPlan = { navController.navigate("AIPlan") }, + onOpenDataPortability = { navController.navigate("DataPortability") }, + onOpenTrainingIntelligence = { navController.navigate("TrainingIntelligence") }, + onOpenExerciseLibrary = { navController.navigate("ExerciseLibrary") }, + onOpenGymProfiles = { navController.navigate("GymProfiles") }, + onOpenBodyWeight = { navController.navigate("BodyWeight") }, + onOpenImportCenter = { navController.navigate("ImportCenter") }, + onOpenPrivacy = { navController.navigate("Privacy") }, + ) + // Safety fallback — all tabs are explicitly covered above. + else -> Unit + } + } + } + + // ── Floating workout pill — sits just above the tab bar + val activeWorkoutActive = !activeWorkoutId.isNullOrBlank() + AnimatedVisibility( + visible = activeWorkoutActive, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 92.dp), + enter = fadeIn(tween(180)) + scaleIn(spring(stiffness = 480f, dampingRatio = 0.45f), initialScale = 0.7f), + exit = fadeOut(tween(140)) + scaleOut(tween(140), targetScale = 0.7f), + ) { + FloatingActiveWorkoutPill( + activeWorkoutStartMs = activeWorkoutStartMs, + activeWorkoutDayName = activeWorkoutDayName, + activeWorkoutDayId = activeWorkoutDayId, + navController = navController, + colors = colors, + ) + } + + // ── Custom full-width glass tab bar + IronLogTabBar( + selectedIndex = selectedTabIndex, + onTabSelected = { index -> + selectedTabIndex = index + scope.launch { pagerState.animateScrollToPage(index) } + }, + hazeState = hazeState, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + ) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// IronLogTabBar +// +// Full-width frosted-glass tab bar with a spring-animated sliding pill +// indicator. The pill glides between tabs with an underdamped spring so it +// feels liquid. Each icon/label crossfades between muted and accent tint as +// the pill passes through. +// +// Layout (64 dp total height): +// ┌──────────────────────────────────────────────────────────┐ +// │ [Home] [Plans] [Log] [Stats] [Settings] │ 64 dp +// │ pill ────────────────> │ +// └──────────────────────────────────────────────────────────┘ +// +// The pill is positioned with absoluteOffset so it sits behind the Row of +// tab columns without needing a SubcomposeLayout. +// ───────────────────────────────────────────────────────────────────────────── +@Composable +private fun IronLogTabBar( + selectedIndex: Int, + onTabSelected: (Int) -> Unit, + hazeState: HazeState, + modifier: Modifier = Modifier, +) { + val colors = useTheme() + val tabCount = TabScreens.size + val barHeight = 64.dp + val pillPadH = 5.dp + val pillPadV = 5.dp + + // Animated slide: 0f = first tab, (tabCount-1).toFloat() = last tab. + // Spring is slightly underdamped so it overshoots and bounces back — the + // liquid-glass feel the user asked for. + val pillOffset by animateFloatAsState( + targetValue = selectedIndex.toFloat(), + animationSpec = spring(stiffness = 400f, dampingRatio = 0.82f), // subtle glide, no bounce + label = "pill_slide", + ) + + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .height(barHeight) + .clip(RoundedCornerShape(999.dp)) + .hazeEffect(hazeState) { noiseFactor = 0f } + .background(colors.tabBg.copy(alpha = 0.55f)), + ) { + val tabWidth = maxWidth / tabCount + + // ── Sliding frosted glass pill + Box( + Modifier + .absoluteOffset( + x = tabWidth * pillOffset + pillPadH, + y = pillPadV, + ) + .size( + width = tabWidth - pillPadH * 2, + height = barHeight - pillPadV * 2, + ) + .clip(RoundedCornerShape(999.dp)) + // Body: top-lit vertical gradient — bright at top, dim at bottom + .background( + Brush.verticalGradient( + listOf( + Color.White.copy(alpha = 0.30f), + Color.White.copy(alpha = 0.10f), + ) + ) + ) + ) + + // ── Tab columns (drawn on top of pill) + Row(Modifier.fillMaxSize()) { + TabScreens.forEachIndexed { index, tab -> + val selected = index == selectedIndex + + // Independent tint spring — settles a touch later than the pill + // so the colour shift trails the glass, mimicking a light-leak. + val tintFraction by animateFloatAsState( + targetValue = if (selected) 1f else 0f, + animationSpec = spring(stiffness = 360f, dampingRatio = 0.80f), + label = "tint_$index", + ) + val tint = lerp(colors.muted, colors.accent, tintFraction) + + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onTabSelected(index) }, + horizontalAlignment = Alignment.CenterHorizontally, + // spacedBy(0) keeps icon and label tightly together as one unit, + // then Arrangement.Center places that unit in the middle of the 64dp bar + verticalArrangement = Arrangement.spacedBy(1.dp, Alignment.CenterVertically), + ) { + Icon( + imageVector = tab.icon, + contentDescription = tab.name, + tint = tint, + modifier = Modifier.size(21.dp), + ) + Text( + text = tab.name, + color = tint, + fontSize = 9.sp, + lineHeight = 9.sp, // collapse implicit leading so text sits flush under icon + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + letterSpacing = 0.1.sp, + ) + } + } + } + } +} + +@Composable +private fun FloatingActiveWorkoutPill( + activeWorkoutStartMs: Long, + activeWorkoutDayName: String?, + activeWorkoutDayId: String?, + navController: NavHostController, + colors: IronLogThemeTokens +) { + var elapsedSeconds by remember(activeWorkoutStartMs) { + mutableLongStateOf( + if (activeWorkoutStartMs > 0L) (System.currentTimeMillis() - activeWorkoutStartMs) / 1000L else 0L + ) + } + LaunchedEffect(activeWorkoutStartMs) { + if (activeWorkoutStartMs > 0L) { + while (true) { + elapsedSeconds = (System.currentTimeMillis() - activeWorkoutStartMs) / 1000L + delay(1000) + } + } + } + val timerText = remember(elapsedSeconds) { + val s = elapsedSeconds; val m = s / 60; val h = m / 60 + if (h > 0) "$h:${(m % 60).toString().padStart(2, '0')}:${(s % 60).toString().padStart(2, '0')}" + else "${(m % 60).toString().padStart(2, '0')}:${(s % 60).toString().padStart(2, '0')}" + } + Surface( + modifier = Modifier + .wrapContentWidth() + .widthIn(max = 280.dp) + .shadow(8.dp, RoundedCornerShape(999.dp)) + .clickable { + navController.navigate( + if (!activeWorkoutDayId.isNullOrBlank()) "ActiveWorkout/${Uri.encode(activeWorkoutDayId)}" else "ActiveWorkout" + ) + }, + color = colors.accent, + shape = RoundedCornerShape(999.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(Icons.Outlined.FitnessCenter, null, tint = colors.textOnAccent.copy(alpha = 0.85f), modifier = Modifier.size(13.dp)) + Text( + text = buildString { + append(activeWorkoutDayName?.uppercase()?.take(12) ?: "WORKOUT") + append(" · ") + append(timerText) + }, + color = colors.textOnAccent, + fontWeight = FontWeight.ExtraBold, + fontSize = IronLogType.meta.fontSize.sp, + letterSpacing = 0.4.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon(Icons.Outlined.ChevronRight, null, tint = colors.textOnAccent.copy(alpha = 0.65f), modifier = Modifier.size(13.dp)) + } + } +} + + diff --git a/app/src/main/java/com/ironlog/app/services/BackupScheduler.kt b/app/src/main/java/com/ironlog/app/services/BackupScheduler.kt new file mode 100644 index 0000000..0e31172 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/services/BackupScheduler.kt @@ -0,0 +1,39 @@ +package com.ironlog.app.services + +import android.content.Context +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.util.Calendar +import java.util.concurrent.TimeUnit + +object BackupScheduler { + private const val BACKUP_WORK_NAME = "ironlog_auto_backup" + + fun scheduleDaily(context: Context, hour: Int, minute: Int) { + val req = PeriodicWorkRequestBuilder(24, TimeUnit.HOURS) + .setInitialDelay(initialDelay(hour, minute), TimeUnit.MILLISECONDS) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + BACKUP_WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + req, + ) + } + + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(BACKUP_WORK_NAME) + } + + private fun initialDelay(hour: Int, minute: Int): Long { + val now = Calendar.getInstance() + val next = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, hour.coerceIn(0, 23)) + set(Calendar.MINUTE, minute.coerceIn(0, 59)) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + if (before(now)) add(Calendar.DAY_OF_YEAR, 1) + } + return (next.timeInMillis - now.timeInMillis).coerceAtLeast(60_000L) + } +} diff --git a/app/src/main/java/com/ironlog/app/services/BackupWorker.kt b/app/src/main/java/com/ironlog/app/services/BackupWorker.kt new file mode 100644 index 0000000..5e4c126 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/services/BackupWorker.kt @@ -0,0 +1,36 @@ +package com.ironlog.app.services + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.ironlog.app.data.repository.ImportExportRepository +import com.ironlog.app.data.repository.SettingsRepository +import java.io.File + +/** Native replacement target for AppRegistry.registerHeadlessTask('IronlogBackupTask', ...). */ +class BackupWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + return runCatching { + val payload = ImportExportRepository().exportDatabase().toString() + val dir = File(applicationContext.filesDir, "backups").apply { mkdirs() } + val stamp = java.text.SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.US).format(java.util.Date()) + val out = File(dir, "ironlog_backup_auto_$stamp.json") + out.writeText(payload) + pruneOldAutoBackups(dir) + SettingsRepository().setString("last_auto_backup_ms", System.currentTimeMillis().toString()) + Result.success() + }.getOrElse { + // After 3 attempts give up rather than retrying a corrupt store indefinitely + if (runAttemptCount >= 3) Result.failure() else Result.retry() + } + } + + private suspend fun pruneOldAutoBackups(dir: File) { + val settings = SettingsRepository() + val keep = settings.getSettingNumber("auto_backup_retention")?.toInt()?.coerceIn(1, 60) ?: 14 + val all = dir.listFiles().orEmpty() + .filter { it.isFile && it.name.startsWith("ironlog_backup_auto_") && it.extension.equals("json", ignoreCase = true) } + .sortedByDescending { it.lastModified() } + all.drop(keep).forEach { runCatching { it.delete() } } + } +} diff --git a/app/src/main/java/com/ironlog/app/services/DailyReminderWorker.kt b/app/src/main/java/com/ironlog/app/services/DailyReminderWorker.kt new file mode 100644 index 0000000..1803139 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/services/DailyReminderWorker.kt @@ -0,0 +1,13 @@ +package com.ironlog.app.services + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters + +class DailyReminderWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + runCatching { WorkoutNotificationBridge.showDailyReminder(applicationContext) } + Result.success() + } +} + diff --git a/app/src/main/java/com/ironlog/app/services/NotificationServices.kt b/app/src/main/java/com/ironlog/app/services/NotificationServices.kt new file mode 100644 index 0000000..439807f --- /dev/null +++ b/app/src/main/java/com/ironlog/app/services/NotificationServices.kt @@ -0,0 +1,601 @@ +package com.ironlog.app.services + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.ironlog.app.MainActivity +import com.ironlog.app.R +import com.ironlog.app.data.objectbox.AppSettingEntity +import com.ironlog.app.data.objectbox.AppSettingEntity_ +import com.ironlog.app.data.objectbox.ObjectBox +import io.objectbox.Box +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.util.Calendar +import org.json.JSONArray +import org.json.JSONObject +import com.ironlog.app.data.objectbox.BodyMeasurementEntity +import com.ironlog.app.data.objectbox.BodyMeasurementEntity_ +import com.ironlog.app.data.objectbox.PlanEntity +import com.ironlog.app.data.objectbox.PlanEntity_ +import com.ironlog.app.data.objectbox.WorkoutEntity +import com.ironlog.app.data.objectbox.WorkoutEntity_ +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.temporal.ChronoUnit + +@Serializable +data class PendingNotificationAction( + val actionId: String? = null, + val pressActionId: String? = null, + val workoutId: String? = null, + val setId: String? = null, + val rawJson: String? = null, + val storedAt: Long? = null, +) + +/** Native equivalent of workoutNotificationBridge.consumePendingAction(). */ +object PendingWorkoutNotificationBridge { + private const val PREFS = "ironlog_pending_notification" + private const val KEY = "pending_action" + private const val MAX_PENDING_AGE_MS = 90_000L + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + suspend fun consumePendingAction(context: Context): PendingNotificationAction? = withContext(Dispatchers.IO) { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val raw = prefs.getString(KEY, null) ?: return@withContext null + prefs.edit().remove(KEY).apply() + val parsed = runCatching { json.decodeFromString(PendingNotificationAction.serializer(), raw) } + .getOrElse { PendingNotificationAction(rawJson = raw) } + val storedAt = parsed.storedAt ?: 0L + if (storedAt > 0L && (System.currentTimeMillis() - storedAt) > MAX_PENDING_AGE_MS) return@withContext null + parsed + } + + fun savePendingAction(context: Context, action: PendingNotificationAction) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putString(KEY, json.encodeToString(action)) + .apply() + } +} + +/** Native equivalent of notificationScheduler.handleNotificationAction(). */ +object NotificationActionRouter { + object Actions { + const val SKIP_REST = "ironlog.skip_rest" + const val ADD_30S = "ironlog.add_30s" + const val FINISH_WORKOUT = "ironlog.finish_workout" + const val START_WORKOUT = "ironlog.start_workout" + const val SNOOZE_1HR = "ironlog.snooze_1hr" + const val LOG_WEIGHT = "ironlog.log_weight" + const val VIEW_PROGRESS = "ironlog.view_progress" + } + + suspend fun handleNotificationAction( + actionId: String, + payload: PendingNotificationAction, + isForeground: Boolean, + ) { + val settings = com.ironlog.app.data.repository.SettingsRepository() + when (actionId) { + Actions.SKIP_REST, + Actions.ADD_30S, + Actions.FINISH_WORKOUT -> { + settings.setString("pending_workout_action", actionId) + } + Actions.START_WORKOUT -> { + settings.setString("pending_nav_route", "Tabs") + } + Actions.SNOOZE_1HR -> { + val until = System.currentTimeMillis() + 60L * 60L * 1000L + settings.setString("snooze_until", until.toString()) + settings.setString("pending_nav_route", "Tabs") + } + Actions.LOG_WEIGHT -> { + settings.setString("pending_nav_route", "BodyWeight") + } + Actions.VIEW_PROGRESS -> { + settings.setString("pending_nav_route", "Stats") + } + } + } +} + +/** Native equivalent of Notifee background ACTION_PRESS handler from index.js. */ +class NotificationActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val actionId = intent.getStringExtra("actionId") ?: intent.action ?: return + PendingWorkoutNotificationBridge.savePendingAction( + context, + PendingNotificationAction( + actionId = actionId, + pressActionId = intent.getStringExtra("pressActionId"), + workoutId = intent.getStringExtra("workoutId"), + setId = intent.getStringExtra("setId"), + storedAt = System.currentTimeMillis(), + ), + ) + } +} + +object WorkoutNotificationBridge { + private const val CHANNEL_ID = "ironlog_workout" + private const val NOTIF_ACTIVE = 7101 + private const val NOTIF_REST = 7102 + private const val NOTIF_REMINDER = 7103 + private val settingsBox: Box by lazy { ObjectBox.store.boxFor(AppSettingEntity::class.java) } + private const val PR_STATUSBAR_KEY = "pr_statusbar_notifications_enabled" + + /** RN parity policy: PR feedback stays in-app (HUD/haptics), never noisy status-bar spam by default. */ + fun shouldPostPrStatusBarNotification(): Boolean { + val row = settingsBox.query(AppSettingEntity_.key.equal(PR_STATUSBAR_KEY)).build().use { it.findFirst() } + return row?.value?.equals("true", ignoreCase = true) ?: false + } + + fun showActiveWorkout(context: Context, title: String = "Workout in progress") { + // Active workout status should always be visible while training; don't suppress + // due to reminder quiet-hours profile gates. + ensureChannel(context) + val openIntent = PendingIntent.getActivity( + context, + 11, + Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val n = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_ironlog) + .setContentTitle("Ironlog") + .setContentText(title) + // Do NOT set setOngoing(true) — ongoing notifications persist in the notification + // shade even after the process is killed (swipe from recents). Regular notifications + // are cancelled automatically by Android when their creating process dies. + .setPriority(NotificationCompat.PRIORITY_LOW) + .setContentIntent(openIntent) + .addAction( + 0, + "Finish", + actionPendingIntent(context, NotificationActionRouter.Actions.FINISH_WORKOUT, 511), + ) + .build() + context.getSystemService(NotificationManager::class.java).notify(NOTIF_ACTIVE, n) + } + + fun showRestTimer(context: Context, secondsRemaining: Int) { + // Rest timer is part of an active workout UX; keep it visible regardless of quiet-hours. + ensureChannel(context) + val n = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_ironlog) + .setContentTitle("Rest Timer") + .setContentText("Rest: ${secondsRemaining.coerceAtLeast(0)}s remaining") + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .addAction( + 0, + "Skip", + actionPendingIntent(context, NotificationActionRouter.Actions.SKIP_REST, 521), + ) + .addAction( + 0, + "+30s", + actionPendingIntent(context, NotificationActionRouter.Actions.ADD_30S, 522), + ) + .build() + context.getSystemService(NotificationManager::class.java).notify(NOTIF_REST, n) + } + + fun clearRestTimer(context: Context) { + context.getSystemService(NotificationManager::class.java).cancel(NOTIF_REST) + } + + fun clearWorkout(context: Context) { + val nm = context.getSystemService(NotificationManager::class.java) + nm.cancel(NOTIF_REST) + nm.cancel(NOTIF_ACTIVE) + } + + fun showDailyReminder(context: Context, text: String = "Time to train. Keep the streak alive.") { + if (!canPostNow()) return + val workoutBox = ObjectBox.store.boxFor(WorkoutEntity::class.java) + val bodyBox = ObjectBox.store.boxFor(BodyMeasurementEntity::class.java) + val planBox = ObjectBox.store.boxFor(PlanEntity::class.java) + + val candidates = SmartCandidateGenerator.buildCandidates(workoutBox, bodyBox, planBox, settingsBox) + val candidate = NotificationPolicyEngine.chooseCandidate(settingsBox, candidates) + if (candidate == null) return + + val decision = NotificationPolicyEngine.shouldSend(settingsBox, key = candidate.key, topic = candidate.topic) + if (!decision.allowed) { + NotificationPolicyEngine.recordSuppressed(settingsBox, candidate.key, candidate.topic, decision.reason ?: "suppressed") + return + } + ensureChannel(context) + val openIntent = PendingIntent.getActivity( + context, + 31, + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra("ironlog_route", "Tabs") + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val n = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_ironlog) + .setContentTitle(candidate.title) + .setContentText(candidate.body) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setContentIntent(openIntent) + .build() + context.getSystemService(NotificationManager::class.java).notify(NOTIF_REMINDER, n) + NotificationPolicyEngine.recordSent(settingsBox, candidate.key, candidate.topic) + } + + fun showTestNotification(context: Context, text: String = "Ironlog notifications are working.") { + if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return + ensureChannel(context) + val openIntent = PendingIntent.getActivity( + context, + 41, + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra("ironlog_route", "Settings") + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val n = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_ironlog) + .setContentTitle("Ironlog Test") + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_REMINDER) + .setAutoCancel(true) + .setContentIntent(openIntent) + .build() + context.getSystemService(NotificationManager::class.java).notify(7104, n) + } + + private fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val nm = context.getSystemService(NotificationManager::class.java) + if (nm.getNotificationChannel(CHANNEL_ID) != null) return + nm.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Ironlog Workout", NotificationManager.IMPORTANCE_DEFAULT).apply { + description = "Active workout and rest timer notifications" + } + ) + } + + private fun canPostNow(): Boolean { + if (!notificationsEnabled()) return false + return !isInQuietHours() + } + + private fun notificationsEnabled(): Boolean { + val row = settingsBox.query(AppSettingEntity_.key.equal("notifications_enabled")).build().use { it.findFirst() } + return row?.value?.equals("true", ignoreCase = true) ?: true + } + + private fun isInQuietHours(): Boolean { + val start = readQuietMinutes("quietHoursStartMinutes", 22 * 60) + val end = readQuietMinutes("quietHoursEndMinutes", 7 * 60) + val cal = Calendar.getInstance() + val now = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE) + return if (start == end) { + false + } else if (start < end) { + now in start until end + } else { + now >= start || now < end + } + } + + private fun readQuietMinutes(key: String, fallback: Int): Int { + val row = settingsBox.query(AppSettingEntity_.key.equal(key)).build().use { it.findFirst() } ?: return fallback + return row.value.toDoubleOrNull()?.toInt()?.coerceIn(0, 1439) ?: fallback + } + + private fun actionPendingIntent(context: Context, actionId: String, requestCode: Int): PendingIntent { + val intent = Intent(context, NotificationActionReceiver::class.java).apply { + putExtra("actionId", actionId) + } + return PendingIntent.getBroadcast( + context, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } +} + +private data class PolicyDecision(val allowed: Boolean, val reason: String? = null) + +private object NotificationPolicyEngine { + private const val DECISION_LOG_KEY = "decision_log_json" + private const val SNOOZE_UNTIL_KEY = "snooze_until" + private const val PROFILE_KEY = "notificationProfile" + private const val DAILY_OVERRIDE_KEY = "maxNotificationsPerDayOverride" + private const val WEEKLY_OVERRIDE_KEY = "maxNotificationsPerWeekOverride" + private const val COOLDOWN_HOURS_KEY = "cooldownHours" + private const val LOG_LIMIT = 64 + + private data class Profile(val dailyCap: Int, val weeklyCap: Int, val baseCooldownHours: Int, val topicCooldown: Map) + + private val conservative = Profile(1, 2, 24, mapOf("training" to 18, "recovery" to 24, "streak" to 20, "bodyweight" to 24, "milestone" to 12, "backup" to 48)) + private val balanced = Profile(1, 3, 12, mapOf("training" to 12, "recovery" to 16, "streak" to 16, "bodyweight" to 18, "milestone" to 8, "backup" to 36)) + private val aggressive = Profile(1, 5, 8, mapOf("training" to 8, "recovery" to 10, "streak" to 12, "bodyweight" to 12, "milestone" to 6, "backup" to 24)) + + fun shouldSend(settingsBox: Box, key: String, topic: String): PolicyDecision { + val now = System.currentTimeMillis() + val snoozeUntil = readLong(settingsBox, SNOOZE_UNTIL_KEY) + if (snoozeUntil != null && snoozeUntil > now) return PolicyDecision(false, "snoozed") + val profile = profile(settingsBox) + val log = readDecisionLog(settingsBox) + val dailyCap = (readDouble(settingsBox, DAILY_OVERRIDE_KEY)?.toInt() ?: profile.dailyCap).coerceIn(1, 3) + val weeklyCap = (readDouble(settingsBox, WEEKLY_OVERRIDE_KEY)?.toInt() ?: profile.weeklyCap).coerceIn(1, 7) + if (countToday(log, now) >= dailyCap) return PolicyDecision(false, "daily_cap") + if (countThisWeek(log, now) >= weeklyCap) return PolicyDecision(false, "weekly_cap") + if (isKeyCooldown(log, key, now, settingsBox, profile)) return PolicyDecision(false, "cooldown_or_topic_gate") + if (isTopicCooldown(log, topic, now, settingsBox, profile)) return PolicyDecision(false, "cooldown_or_topic_gate") + return PolicyDecision(true, "selected") + } + + fun recordSent(settingsBox: Box, key: String, topic: String) { + appendDecision(settingsBox, JSONObject().put("outcome", "sent").put("key", key).put("topic", topic).put("reason", "selected").put("at", isoNow())) + } + + fun recordSuppressed(settingsBox: Box, key: String, topic: String, reason: String) { + val latest = readDecisionLog(settingsBox).optJSONObject(0) + val sixHoursMs = 6L * 3600L * 1000L + val sameAsLatest = latest != null && + latest.optString("outcome") == "suppressed" && + latest.optString("key") == key && + latest.optString("reason") == reason && + (System.currentTimeMillis() - parseTime(latest.optString("at"))) <= sixHoursMs + if (sameAsLatest) return + appendDecision(settingsBox, JSONObject().put("outcome", "suppressed").put("key", key).put("topic", topic).put("reason", reason).put("at", isoNow())) + } + + private fun appendDecision(settingsBox: Box, entry: JSONObject) { + val current = readDecisionLog(settingsBox) + val merged = JSONArray() + merged.put(entry) + for (i in 0 until current.length()) { + if (merged.length() >= LOG_LIMIT) break + merged.put(current.opt(i)) + } + writeSetting(settingsBox, DECISION_LOG_KEY, merged.toString(), "json") + } + + private fun countToday(log: JSONArray, nowMs: Long): Int { + val c = Calendar.getInstance().apply { timeInMillis = nowMs; set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) } + val start = c.timeInMillis + var count = 0 + for (i in 0 until log.length()) { + val e = log.optJSONObject(i) ?: continue + if (e.optString("outcome") != "sent") continue + if (parseTime(e.optString("at")) >= start) count++ + } + return count + } + + private fun countThisWeek(log: JSONArray, nowMs: Long): Int { + val cal = Calendar.getInstance().apply { timeInMillis = nowMs } + val day = if (cal.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) 7 else cal.get(Calendar.DAY_OF_WEEK) - 1 + cal.add(Calendar.DAY_OF_MONTH, -day + 1) + cal.set(Calendar.HOUR_OF_DAY, 0) + cal.set(Calendar.MINUTE, 0) + cal.set(Calendar.SECOND, 0) + cal.set(Calendar.MILLISECOND, 0) + val start = cal.timeInMillis + var count = 0 + for (i in 0 until log.length()) { + val e = log.optJSONObject(i) ?: continue + if (e.optString("outcome") != "sent") continue + if (parseTime(e.optString("at")) >= start) count++ + } + return count + } + + private fun isKeyCooldown(log: JSONArray, key: String, nowMs: Long, settingsBox: Box, profile: Profile): Boolean { + val defaultHours = (readDouble(settingsBox, COOLDOWN_HOURS_KEY)?.toInt() ?: profile.baseCooldownHours).coerceAtLeast(1) + val latest = findLatestSentForKey(log, key) ?: return false + return nowMs - latest < defaultHours * 3600_000L + } + + private fun isTopicCooldown(log: JSONArray, topic: String, nowMs: Long, settingsBox: Box, profile: Profile): Boolean { + val row = settingsBox.query(AppSettingEntity_.key.equal("perTopicCooldownHours")).build().use { it.findFirst() } + val perTopic = row?.value?.takeIf { it.isNotBlank() }?.let { runCatching { JSONObject(it) }.getOrNull() } + val topicHours = perTopic?.optDouble(topic, Double.NaN)?.takeIf { it.isFinite() }?.toInt() + ?: profile.topicCooldown[topic] + ?: profile.baseCooldownHours + val latest = findLatestSentForTopic(log, topic) ?: return false + return nowMs - latest < topicHours * 3600_000L + } + + private fun findLatestSentForKey(log: JSONArray, key: String): Long? { + for (i in 0 until log.length()) { + val e = log.optJSONObject(i) ?: continue + if (e.optString("outcome") == "sent" && e.optString("key") == key) return parseTime(e.optString("at")) + } + return null + } + + private fun findLatestSentForTopic(log: JSONArray, topic: String): Long? { + for (i in 0 until log.length()) { + val e = log.optJSONObject(i) ?: continue + if (e.optString("outcome") == "sent" && e.optString("topic") == topic) return parseTime(e.optString("at")) + } + return null + } + + private fun readDecisionLog(settingsBox: Box): JSONArray { + val row = settingsBox.query(AppSettingEntity_.key.equal(DECISION_LOG_KEY)).build().use { it.findFirst() } ?: return JSONArray() + return runCatching { JSONArray(row.value) }.getOrElse { JSONArray() } + } + + private fun profile(settingsBox: Box): Profile { + val p = settingsBox.query(AppSettingEntity_.key.equal(PROFILE_KEY)).build().use { it.findFirst() }?.value?.lowercase().orEmpty() + return when (p) { + "conservative" -> conservative + "aggressive" -> aggressive + else -> balanced + } + } + + private fun readLong(settingsBox: Box, key: String): Long? = + settingsBox.query(AppSettingEntity_.key.equal(key)).build().use { it.findFirst() }?.value?.toLongOrNull() + + private fun readDouble(settingsBox: Box, key: String): Double? = + settingsBox.query(AppSettingEntity_.key.equal(key)).build().use { it.findFirst() }?.value?.toDoubleOrNull() + + private fun writeSetting(settingsBox: Box, key: String, value: String, type: String) { + val now = System.currentTimeMillis() + val row = settingsBox.query(AppSettingEntity_.key.equal(key)).build().use { it.findFirst() } + if (row != null) { + row.value = value + row.valueType = type + row.updatedAt = now + settingsBox.put(row) + } else { + settingsBox.put(AppSettingEntity().apply { + this.key = key + this.value = value + this.valueType = type + this.updatedAt = now + }) + } + } + + private fun isoNow(): String = java.time.Instant.now().toString() + private fun parseTime(iso: String?): Long = runCatching { java.time.Instant.parse(iso).toEpochMilli() }.getOrElse { 0L } + + fun chooseCandidate(settingsBox: Box, candidates: List): NotificationCandidate? { + val profile = profile(settingsBox) + val log = readDecisionLog(settingsBox) + val nowMs = System.currentTimeMillis() + + val sorted = candidates.sortedByDescending { it.score } + for (c in sorted) { + if (isKeyCooldown(log, c.key, nowMs, settingsBox, profile)) continue + if (isTopicCooldown(log, c.topic, nowMs, settingsBox, profile)) continue + return c + } + return null + } +} + +data class NotificationCandidate( + val key: String, + val topic: String, + val title: String, + val body: String, + val score: Int +) + +object SmartCandidateGenerator { + fun buildCandidates( + workoutBox: Box, + bodyBox: Box, + planBox: Box, + settingsBox: Box + ): List { + val candidates = mutableListOf() + val now = System.currentTimeMillis() + val todayStr = Instant.ofEpochMilli(now).atZone(ZoneId.systemDefault()).toLocalDate() + + val history = workoutBox.query(WorkoutEntity_.status.equal("completed")).orderDesc(WorkoutEntity_.createdAt).build().use { it.find() } + val hasWorkoutToday = history.any { + Instant.ofEpochMilli(it.startedAt).atZone(ZoneId.systemDefault()).toLocalDate() == todayStr + } + + val activePlan = planBox.query(PlanEntity_.isActive.equal(true)).build().use { it.findFirst() } + val targetDays = activePlan?.days?.size?.coerceIn(1, 7) ?: 3 + + val startOfWeek = LocalDate.now().with(java.time.temporal.TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) + val doneThisWeek = history.count { + val d = Instant.ofEpochMilli(it.startedAt).atZone(ZoneId.systemDefault()).toLocalDate() + !d.isBefore(startOfWeek) + } + val daysLeft = 8 - LocalDate.now().dayOfWeek.value + + // 1. Plan Aware Training Candidate + if (!hasWorkoutToday && doneThisWeek < targetDays && (targetDays - doneThisWeek) >= daysLeft) { + val remaining = targetDays - doneThisWeek + candidates.add(NotificationCandidate( + key = "train_reminder_plan_aware", + topic = "training", + title = "Training reminder", + body = "$remaining session${if (remaining > 1) "s" else ""} left this week. Aim to train at your usual time.", + score = 100 + )) + } + + // 2. Bodyweight Reminder + val bodyweightEntries = bodyBox.all + val hasBodyweightToday = bodyweightEntries.any { + Instant.ofEpochMilli(it.measuredAt).atZone(ZoneId.systemDefault()).toLocalDate() == todayStr + } + if (!hasBodyweightToday && history.isNotEmpty()) { + candidates.add(NotificationCandidate( + key = "bw_reminder", + topic = "bodyweight", + title = "Log body weight", + body = "A quick bodyweight entry keeps your trend accurate.", + score = 40 + )) + } + + // 3. Streak Preserve + val currentStreak = calculateStreak(history) + if (currentStreak >= 1 && !hasWorkoutToday && doneThisWeek < targetDays) { + candidates.add(NotificationCandidate( + key = "streak_preserve", + topic = "streak", + title = "Streak active", + body = "One more session this week keeps your streak alive.", + score = 55 + )) + } + + // 4. Backup Integrity + val lastBackup = settingsBox.query(AppSettingEntity_.key.equal("last_auto_backup_ms")).build().use { it.findFirst() }?.value?.toLongOrNull() ?: 0L + if (history.isNotEmpty() && (now - lastBackup) > 3L * 24L * 3600L * 1000L) { + candidates.add(NotificationCandidate( + key = "backup_integrity", + topic = "backup", + title = "Backup reminder", + body = "Your training data has unsynced changes. Run a backup today.", + score = 58 + )) + } + + return candidates + } + + private fun calculateStreak(history: List): Int { + if (history.isEmpty()) return 0 + var streak = 0 + var currentWeek = LocalDate.now().with(java.time.temporal.TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) + val historyDates = history.map { + Instant.ofEpochMilli(it.startedAt).atZone(ZoneId.systemDefault()).toLocalDate().with(java.time.temporal.TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) + }.toSet() + + while (historyDates.contains(currentWeek)) { + streak++ + currentWeek = currentWeek.minusWeeks(1) + } + return streak + } +} diff --git a/app/src/main/java/com/ironlog/app/services/ReminderScheduler.kt b/app/src/main/java/com/ironlog/app/services/ReminderScheduler.kt new file mode 100644 index 0000000..5584645 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/services/ReminderScheduler.kt @@ -0,0 +1,40 @@ +package com.ironlog.app.services + +import android.content.Context +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.util.Calendar +import java.util.concurrent.TimeUnit + +object ReminderScheduler { + private const val DAILY_REMINDER_WORK = "ironlog_daily_reminder" + + fun scheduleDailyReminder(context: Context, hour: Int, minute: Int) { + val initialDelay = computeInitialDelayMs(hour, minute) + val req = PeriodicWorkRequestBuilder(24, TimeUnit.HOURS) + .setInitialDelay(initialDelay, TimeUnit.MILLISECONDS) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + DAILY_REMINDER_WORK, + ExistingPeriodicWorkPolicy.UPDATE, + req, + ) + } + + fun cancelDailyReminder(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(DAILY_REMINDER_WORK) + } + + private fun computeInitialDelayMs(hour: Int, minute: Int): Long { + val now = Calendar.getInstance() + val next = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, hour.coerceIn(0, 23)) + set(Calendar.MINUTE, minute.coerceIn(0, 59)) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + if (before(now)) add(Calendar.DAY_OF_YEAR, 1) + } + return (next.timeInMillis - now.timeInMillis).coerceAtLeast(60_000L) + } +} diff --git a/app/src/main/java/com/ironlog/app/services/ShareService.kt b/app/src/main/java/com/ironlog/app/services/ShareService.kt new file mode 100644 index 0000000..8a47866 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/services/ShareService.kt @@ -0,0 +1,132 @@ +package com.ironlog.app.services + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Shader +import androidx.core.content.FileProvider +import java.io.File +import java.io.FileOutputStream + +object ShareService { + data class ShareMetric( + val label: String, + val value: String, + ) + + fun sharePlainText(context: Context, title: String, text: String) { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, title) + putExtra(Intent.EXTRA_TEXT, text) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(Intent.createChooser(send, title).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + fun shareFile(context: Context, title: String, uri: android.net.Uri, mimeType: String) { + val send = Intent(Intent.ACTION_SEND).apply { + type = mimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(Intent.createChooser(send, title).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + fun shareMinimalCardImage( + context: Context, + title: String, + headline: String, + metrics: List, + footnote: String? = null, + ) { + runCatching { + val width = 1080 + val height = 1350 + val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + + val bg = Paint(Paint.ANTI_ALIAS_FLAG).apply { + shader = LinearGradient( + 0f, 0f, width.toFloat(), height.toFloat(), + Color.parseColor("#0D1017"), + Color.parseColor("#161B25"), + Shader.TileMode.CLAMP, + ) + } + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bg) + + val card = RectF(60f, 120f, width - 60f, height - 120f) + val cardPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#1F2532") } + val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 2f + color = Color.parseColor("#2A3344") + } + canvas.drawRoundRect(card, 46f, 46f, cardPaint) + canvas.drawRoundRect(card, 46f, 46f, strokePaint) + + val accent = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#FF5A1F") } + canvas.drawRoundRect(RectF(card.left + 24f, card.top + 24f, card.left + 34f, card.bottom - 24f), 8f, 8f, accent) + + val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#98A2B8") + textSize = 36f + isFakeBoldText = true + letterSpacing = 0.08f + } + val headlinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textSize = 72f + isFakeBoldText = true + } + val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#98A2B8") + textSize = 28f + isFakeBoldText = true + letterSpacing = 0.05f + } + val valuePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textSize = 48f + isFakeBoldText = true + } + val footPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#B7C0D4") + textSize = 30f + } + + canvas.drawText(title.uppercase(), card.left + 70f, card.top + 90f, titlePaint) + canvas.drawText(headline, card.left + 70f, card.top + 190f, headlinePaint) + + var y = card.top + 300f + metrics.forEach { + canvas.drawText(it.label.uppercase(), card.left + 70f, y, labelPaint) + y += 58f + canvas.drawText(it.value, card.left + 70f, y, valuePaint) + y += 84f + } + + footnote?.takeIf { it.isNotBlank() }?.let { + canvas.drawText(it, card.left + 70f, card.bottom - 70f, footPaint) + } + + val outFile = File(context.cacheDir, "ironlog_share_${System.currentTimeMillis()}.png") + FileOutputStream(outFile).use { out -> bmp.compress(Bitmap.CompressFormat.PNG, 100, out) } + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", outFile) + shareFile(context, "Share", uri, "image/png") + }.onFailure { + sharePlainText(context, title, buildString { + appendLine(headline) + metrics.forEach { appendLine("${it.label}: ${it.value}") } + if (!footnote.isNullOrBlank()) appendLine(footnote) + }) + } + } +} diff --git a/app/src/main/java/com/ironlog/app/services/WorkoutForegroundService.kt b/app/src/main/java/com/ironlog/app/services/WorkoutForegroundService.kt new file mode 100644 index 0000000..c4bd349 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/services/WorkoutForegroundService.kt @@ -0,0 +1,273 @@ +package com.ironlog.app.services + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import androidx.compose.ui.graphics.toArgb +import androidx.core.app.NotificationCompat +import com.ironlog.app.MainActivity +import com.ironlog.app.R +import com.ironlog.app.data.objectbox.AppSettingEntity +import com.ironlog.app.data.objectbox.AppSettingEntity_ +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.ui.theme.IronLogThemes +import org.json.JSONObject + +/** + * Foreground service that keeps the workout session alive when the app is in the background. + * Displays one persistent, theme-tinted notification with live session and rest-timer info. + */ +class WorkoutForegroundService : Service() { + + private val notificationManager by lazy { + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + private val settingsBox by lazy { ObjectBox.store.boxFor(AppSettingEntity::class.java) } + + private var timerThread: Thread? = null + private var running = false + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val workoutName = intent?.getStringExtra(EXTRA_WORKOUT_NAME) ?: "Workout" + val startMs = intent?.getLongExtra(EXTRA_START_MS, System.currentTimeMillis()) + ?: System.currentTimeMillis() + + ensureChannel() + // The foreground service is now the single source for workout notifications. + runCatching { + notificationManager.cancel(7101) + notificationManager.cancel(7102) + } + startForeground(NOTIFICATION_ID, buildNotification(workoutName, startMs)) + + running = false + timerThread?.let { old -> old.interrupt(); runCatching { old.join(200) } } + timerThread = null + + running = true + timerThread = Thread { + var lastHapticSec = Int.MIN_VALUE + while (running && !Thread.currentThread().isInterrupted) { + try { + Thread.sleep(1000) + if (running) { + runCatching { + notificationManager.notify( + NOTIFICATION_ID, + buildNotification(workoutName, startMs), + ) + } + // Fired from the service so countdown haptics continue on lockscreen/background. + runCatching { + val restEndMs = readSettingString("active_workout_rest_end_ms")?.toLongOrNull() ?: 0L + val remaining = if (restEndMs > System.currentTimeMillis()) { + ((restEndMs - System.currentTimeMillis()) / 1000L).toInt().coerceAtLeast(0) + } else 0 + if (remaining in 1..5 && remaining != lastHapticSec && hapticsEnabled()) { + triggerCountdownHaptic(remaining) + lastHapticSec = remaining + } else if (remaining == 0 || restEndMs == 0L) { + lastHapticSec = Int.MIN_VALUE + } + } + } + } catch (_: InterruptedException) { + break + } + } + }.also { it.isDaemon = true; it.start() } + + return START_STICKY + } + + override fun onDestroy() { + stopTimer() + super.onDestroy() + } + + /** Keep the ObjectBox row and resume keys intact when the task is swiped away. */ + override fun onTaskRemoved(rootIntent: Intent?) { + stopTimer() + stopSelf() + super.onTaskRemoved(rootIntent) + } + + private fun stopTimer() { + running = false + timerThread?.interrupt() + timerThread = null + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun ensureChannel() { + if (notificationManager.getNotificationChannel(CHANNEL_ID) != null) return + val channel = NotificationChannel( + CHANNEL_ID, + "Workout in Progress", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Live active-workout notification." + setShowBadge(false) + } + notificationManager.createNotificationChannel(channel) + } + + private fun buildNotification(workoutName: String, startMs: Long): Notification { + val elapsedSec = (System.currentTimeMillis() - startMs) / 1000L + val timeStr = elapsedSec.toTimerString() + + val setLabel = readSettingString("active_workout_set_label").orEmpty() + val restEndMs = readSettingString("active_workout_rest_end_ms")?.toLongOrNull() ?: 0L + val restRemainingSec = if (restEndMs > System.currentTimeMillis()) { + ((restEndMs - System.currentTimeMillis()) / 1000L).toInt().coerceAtLeast(0) + } else 0 + + val displaySet = if (setLabel.isBlank()) "--" else setLabel + val summary = if (restRemainingSec > 0) { + "Set: $displaySet - Rest ${restRemainingSec}s - Session $timeStr" + } else { + "Set: $displaySet - Session $timeStr" + } + + val tapPendingIntent = PendingIntent.getActivity( + this, + 700, + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra("navigate_to_workout", true) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_ironlog) + .setContentTitle("IRONLOG - $workoutName") + .setContentText(summary) + .setStyle(NotificationCompat.BigTextStyle().bigText(summary)) + .setContentIntent(tapPendingIntent) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setSilent(true) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setColor(readCurrentAccentColor()) + .setColorized(true) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + } + if (restRemainingSec > 0) { + builder.addAction( + 0, + "Skip", + workoutActionActivityIntent(NotificationActionRouter.Actions.SKIP_REST, 701), + ) + builder.addAction( + 0, + "+30s", + workoutActionActivityIntent(NotificationActionRouter.Actions.ADD_30S, 702), + ) + } + builder.addAction( + 0, + "Finish", + workoutActionActivityIntent(NotificationActionRouter.Actions.FINISH_WORKOUT, 703), + ) + return builder.build() + } + + private fun readSettingString(key: String): String? { + return settingsBox.query(AppSettingEntity_.key.equal(key)).build().use { it.findFirst() }?.value + } + + private fun hapticsEnabled(): Boolean { + val raw = readSettingString("ironlog_settings") ?: return true + return runCatching { + JSONObject(raw).optBoolean("hapticFeedback", true) + }.getOrDefault(true) + } + + private fun readCurrentAccentColor(): Int { + val raw = readSettingString("ironlog_settings").orEmpty() + val theme = runCatching { JSONObject(raw).optString("theme", IronLogThemes.DEFAULT_THEME) } + .getOrDefault(IronLogThemes.DEFAULT_THEME) + .ifBlank { IronLogThemes.DEFAULT_THEME } + return IronLogThemes.byName(theme).accent.toArgb() + } + + private fun workoutActionActivityIntent(actionId: String, requestCode: Int): PendingIntent { + return PendingIntent.getActivity( + this, + requestCode, + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra("navigate_to_workout", true) + putExtra("actionId", actionId) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + @Suppress("DEPRECATION") + private fun triggerCountdownHaptic(secondsRemaining: Int) { + val vib: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + (getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator + } else { + getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + if (vib == null || !vib.hasVibrator()) return + val (duration, amplitude) = when (secondsRemaining) { + 1 -> 40L to 255 + 2 -> 30L to 220 + 3 -> 22L to 180 + 4 -> 16L to 140 + else -> 12L to 100 + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + runCatching { vib.vibrate(VibrationEffect.createOneShot(duration, amplitude)) } + } else { + runCatching { vib.vibrate(duration) } + } + } + + companion object { + private const val CHANNEL_ID = "ironlog_workout_timer" + private const val NOTIFICATION_ID = 7001 + private const val EXTRA_WORKOUT_NAME = "workout_name" + private const val EXTRA_START_MS = "start_ms" + + fun start(context: Context, workoutName: String, startMs: Long) { + val intent = Intent(context, WorkoutForegroundService::class.java).apply { + putExtra(EXTRA_WORKOUT_NAME, workoutName) + putExtra(EXTRA_START_MS, startMs) + } + context.startForegroundService(intent) + } + + fun stop(context: Context) { + context.stopService(Intent(context, WorkoutForegroundService::class.java)) + } + } +} + +private fun Long.toTimerString(): String { + val h = this / 3600 + val m = (this % 3600) / 60 + val s = this % 60 + return if (h > 0) { + "$h:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}" + } else { + "${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}" + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/CardGradient.kt b/app/src/main/java/com/ironlog/app/ui/CardGradient.kt new file mode 100644 index 0000000..9da94db --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/CardGradient.kt @@ -0,0 +1,45 @@ +package com.ironlog.app.ui + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color + +/** + * Returns a [Brush] that slowly sweeps a diagonal gradient highlight across the card — the + * "live wallpaper" breathing effect. Works for any theme because the only input is [accent] + * which is already the current-theme accent colour. + * + * The sweep period is 5 s (forward) + 5 s (reverse) = 10 s full cycle, which feels organic + * without being distracting during a workout. + */ +@Composable +fun rememberAnimatedCardBrush(accent: Color): Brush { + val infiniteTransition = rememberInfiniteTransition(label = "cardGradient") + val shift by infiniteTransition.animateFloat( + initialValue = -600f, + targetValue = 1800f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 5_000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "gradientShift", + ) + return Brush.linearGradient( + colorStops = arrayOf( + 0.00f to accent.copy(alpha = 0.03f), + 0.35f to accent.copy(alpha = 0.20f), + 0.65f to accent.copy(alpha = 0.28f), + 1.00f to accent.copy(alpha = 0.04f), + ), + start = Offset(shift, 0f), + end = Offset(shift + 900f, Float.POSITIVE_INFINITY), + ) +} diff --git a/app/src/main/java/com/ironlog/app/ui/IronLogApp.kt b/app/src/main/java/com/ironlog/app/ui/IronLogApp.kt new file mode 100644 index 0000000..a48936b --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/IronLogApp.kt @@ -0,0 +1,192 @@ +package com.ironlog.app.ui + +import android.app.Application +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewModelScope +import com.ironlog.app.data.repository.ExerciseRepository +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.navigation.AppNavigator +import com.ironlog.app.ui.context.ThemeProvider +import com.ironlog.app.ui.context.ThemeRuntime +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogThemeTokens +import com.ironlog.app.ui.theme.ObsidianShapes +import com.ironlog.app.ui.theme.ObsidianTypography +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +sealed interface BootstrapUiState { + data object Booting : BootstrapUiState + data class StartupError(val throwable: Throwable) : BootstrapUiState + data object WaitingForSplash : BootstrapUiState + data object Ready : BootstrapUiState +} + +class IronLogAppViewModel(application: Application) : AndroidViewModel(application) { + private val exerciseRepository = ExerciseRepository(application.applicationContext) + private val _state = MutableStateFlow(BootstrapUiState.Booting) + val state: StateFlow = _state.asStateFlow() + private var bootAttempt = 0 + + init { bootstrap() } + + fun retry() { + bootAttempt += 1 + bootstrap() + } + + fun splashFinished() { + if (_state.value is BootstrapUiState.WaitingForSplash) _state.value = BootstrapUiState.Ready + } + + private fun bootstrap() { + _state.value = BootstrapUiState.Booting + viewModelScope.launch { + try { + exerciseRepository.seedExercisesIfNeeded() + exerciseRepository.backfillExerciseMusclesIfNeeded() + _state.value = BootstrapUiState.WaitingForSplash + // Safety net: if the splash composable is removed before onFinish fires + // (e.g., configuration change without Activity recreation), force Ready + // after 5 seconds so the app never stays stuck on the splash indefinitely. + kotlinx.coroutines.delay(5_000) + if (_state.value is BootstrapUiState.WaitingForSplash) { + _state.value = BootstrapUiState.Ready + } + } catch (t: Throwable) { + _state.value = BootstrapUiState.StartupError(t) + } + } + } +} + +@Composable +fun IronLogApp(viewModel: IronLogAppViewModel = viewModel()) { + val state by viewModel.state.collectAsState() + val activeTheme by ThemeRuntime.themeName.collectAsState() + val settingsRepository = remember { SettingsRepository() } + var onboardingComplete by remember { mutableStateOf(false) } + // Block AppNavigator from rendering until the onboarding flag has been read. + // NavHost.startDestination is only consumed once — if we render before the flag + // is loaded we'll always route already-onboarded users to Onboarding. + var onboardingLoaded by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + val raw = settingsRepository.getString("ironlog_settings") + val json = runCatching { org.json.JSONObject(raw ?: "{}") }.getOrDefault(org.json.JSONObject()) + val persistedTheme = json.optString("theme", com.ironlog.app.ui.theme.IronLogThemes.DEFAULT_THEME) + ThemeRuntime.setTheme(persistedTheme) + onboardingComplete = settingsRepository.getBoolean("onboarding_complete", false) + onboardingLoaded = true + } + + ThemeProvider(themeName = activeTheme) { + val colors = useTheme() + MaterialTheme( + colorScheme = colors.toMaterialColorScheme(), + typography = ObsidianTypography, + shapes = ObsidianShapes, + ) { + when (val s = state) { + BootstrapUiState.Booting -> BootPlaceholder() + is BootstrapUiState.StartupError -> StartupErrorScreen(s.throwable, onRetry = viewModel::retry) + BootstrapUiState.WaitingForSplash -> com.ironlog.app.ui.screens.SplashScreen(onFinish = viewModel::splashFinished) + BootstrapUiState.Ready -> if (onboardingLoaded) { + AppNavigator(onboardingComplete = onboardingComplete) + } else { + BootPlaceholder() + } + } + } + } +} + +private fun IronLogThemeTokens.toMaterialColorScheme() = ColorScheme( + primary = accent, + onPrimary = textOnAccent, + primaryContainer = accentSoft, + onPrimaryContainer = text, + inversePrimary = accent, + secondary = chartSecondary, + onSecondary = textOnAccent, + secondaryContainer = surface, + onSecondaryContainer = text, + tertiary = chartTertiary, + onTertiary = textOnAccent, + tertiaryContainer = surface, + onTertiaryContainer = text, + background = bg, + onBackground = text, + surface = card, + onSurface = text, + surfaceVariant = surface, + onSurfaceVariant = subtext, + surfaceTint = Color.Transparent, + inverseSurface = text, + inverseOnSurface = bg, + error = danger, + onError = onDanger, + errorContainer = danger, + onErrorContainer = onDanger, + outline = cardBorder, + outlineVariant = cardBorder, + scrim = Color.Black +) + +@Composable +private fun BootPlaceholder() { + val c = useTheme() + Box(Modifier.fillMaxSize().background(c.bg)) +} + +@Composable +private fun StartupErrorScreen(error: Throwable, onRetry: () -> Unit) { + val c = useTheme() + Column( + modifier = Modifier + .fillMaxSize() + .background(c.bg) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("Ironlog", color = c.text, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineLarge) + Text("Startup failed", color = c.danger, fontWeight = FontWeight.ExtraBold, style = MaterialTheme.typography.titleMedium) + Text( + text = error.message ?: "Something went wrong while loading your data.", + color = c.muted, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 24.dp), + ) + Button(onClick = onRetry, colors = ButtonDefaults.buttonColors(containerColor = c.accent)) { + Text("TRY AGAIN", color = c.textOnAccent, fontWeight = FontWeight.ExtraBold) + } + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/components/EmptyState.kt b/app/src/main/java/com/ironlog/app/ui/components/EmptyState.kt new file mode 100644 index 0000000..79326e9 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/components/EmptyState.kt @@ -0,0 +1,85 @@ +package com.ironlog.app.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogType + +/** + * Standard empty state used across all screens. + * Shows an icon, headline, body text, and an optional CTA button. + */ +@Composable +fun EmptyState( + icon: ImageVector, + headline: String, + body: String, + modifier: Modifier = Modifier, + ctaLabel: String? = null, + onCta: (() -> Unit)? = null, +) { + val c = useTheme() + Box( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 40.dp, horizontal = 24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = c.muted, + modifier = Modifier.size(44.dp), + ) + Text( + headline, + color = c.text, + fontWeight = FontWeight(IronLogType.section.fontWeight), + fontSize = IronLogType.section.fontSize.sp, + textAlign = TextAlign.Center, + ) + Text( + body, + color = c.muted, + fontSize = IronLogType.body.fontSize.sp, + textAlign = TextAlign.Center, + ) + if (ctaLabel != null && onCta != null) { + Spacer(Modifier.height(4.dp)) + Button( + onClick = onCta, + colors = ButtonDefaults.buttonColors(containerColor = c.accent), + ) { + Text( + ctaLabel, + fontWeight = FontWeight(IronLogType.button.fontWeight), + fontSize = IronLogType.button.fontSize.sp, + letterSpacing = IronLogType.button.letterSpacing.sp, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/components/IronGradeBadge.kt b/app/src/main/java/com/ironlog/app/ui/components/IronGradeBadge.kt new file mode 100644 index 0000000..c12f038 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/components/IronGradeBadge.kt @@ -0,0 +1,51 @@ +package com.ironlog.app.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import com.ironlog.app.R + +@Composable +fun IronGradeBadge( + rank: String, + modifier: Modifier = Modifier, + accent: Color = ironGradeColor(rank), +) { + Image( + painter = painterResource(ironGradeDrawable(rank)), + contentDescription = "$rank Iron Ledger badge", + contentScale = ContentScale.Fit, + modifier = modifier, + ) +} + +@DrawableRes +private fun ironGradeDrawable(rank: String): Int = when (rank) { + "Uncalibrated", "E" -> R.drawable.iron_grade_uncalibrated + "Graphite", "D" -> R.drawable.iron_grade_graphite + "Iron", "C" -> R.drawable.iron_grade_iron + "Steel", "B" -> R.drawable.iron_grade_steel + "Titanium", "A" -> R.drawable.iron_grade_titanium + "Obsidian", "S" -> R.drawable.iron_grade_obsidian + "Iridium" -> R.drawable.iron_grade_iridium + "Aether" -> R.drawable.iron_grade_aether + "Apex" -> R.drawable.iron_grade_apex + else -> R.drawable.iron_grade_uncalibrated +} + +fun ironGradeColor(rank: String): Color = when (rank) { + "Uncalibrated", "E" -> Color(0xFF9E9E9E) + "Graphite", "D" -> Color(0xFF8D8D8D) + "Iron", "C" -> Color(0xFFB0BEC5) + "Steel", "B" -> Color(0xFF90A4AE) + "Titanium", "A" -> Color(0xFF64B5F6) + "Obsidian", "S" -> Color(0xFFB39DDB) + "Iridium" -> Color(0xFFFFB74D) + "Aether" -> Color(0xFF80CBC4) + "Apex" -> Color(0xFFFFD700) + else -> Color(0xFF9E9E9E) +} diff --git a/app/src/main/java/com/ironlog/app/ui/components/ScreenHeader.kt b/app/src/main/java/com/ironlog/app/ui/components/ScreenHeader.kt new file mode 100644 index 0000000..9c01126 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/components/ScreenHeader.kt @@ -0,0 +1,119 @@ +package com.ironlog.app.ui.components + +// FIXED: 6 — ScreenHeader updated with subtitle + action slot; also used as a tab-screen page header + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogType + +/** + * Standard back-navigation header used on all push-navigated screens. + * Renders: [← back arrow] [eyebrow / screen title] + */ +@Composable +fun ScreenHeader( + title: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, +) { + val c = useTheme() + Row( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Use IconButton instead of raw Icon + clickable to get the 48dp minimum touch target. + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + tint = c.accent, + modifier = Modifier.size(24.dp), + ) + } + Column { + Text( + text = title, + color = c.text, + fontSize = IronLogType.section.fontSize.sp, + fontWeight = FontWeight(IronLogType.section.fontWeight), + letterSpacing = IronLogType.section.letterSpacing.sp, + ) + subtitle?.let { + Text( + text = it, + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } + } +} + +/** + * Page-level header for tab screens (no back button). + * Renders: eyebrow label + large title + optional subtitle + optional trailing action. + */ +@Composable +fun PageHeader( + eyebrow: String, + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + action: @Composable (() -> Unit)? = null, +) { + val c = useTheme() + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + Column(Modifier.weight(1f)) { + Text( + eyebrow, + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + Text( + title, + color = c.text, + fontSize = IronLogType.title.fontSize.sp, + fontWeight = FontWeight(IronLogType.display.fontWeight), + lineHeight = IronLogType.title.lineHeight.sp, + letterSpacing = (-0.3).sp, + ) + subtitle?.let { + Text( + it, + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + action?.invoke() + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/components/SetRow.kt b/app/src/main/java/com/ironlog/app/ui/components/SetRow.kt new file mode 100644 index 0000000..babc596 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/components/SetRow.kt @@ -0,0 +1,286 @@ +package com.ironlog.app.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.state.LoggedSet +import com.ironlog.app.ui.state.WorkoutAction +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.util.formatDurationShort +import com.ironlog.app.util.formatWeightFromKg + +private val SetTypes = listOf("normal", "warmup", "drop", "failure", "amrap") +private val TypeLabel = mapOf( + "normal" to "W", + "warmup" to "WU", + "drop" to "DS", + "failure" to "F", + "amrap" to "AMRAP", +) + +@Composable +fun SetRow( + set: LoggedSet, + setIndex: Int, + exIndex: Int, + dispatch: (WorkoutAction) -> Unit, + effortTracking: String, + hapticFeedback: Boolean, + weightUnit: String = "kg", + trackingType: String = "weight_reps", +) { + val c = useTheme() + var noteOpen by remember(set.id) { mutableStateOf(false) } + var editOpen by remember(set.id) { mutableStateOf(false) } + var editWeight by remember(set.id, set.weight) { mutableStateOf(set.weight.toString()) } + var editReps by remember(set.id, set.reps) { mutableStateOf(set.reps.toString()) } + + val typeKey = set.type.ifBlank { "normal" } + val typeColor: Color = when (typeKey) { + "warmup" -> c.info + "drop" -> c.accent + "failure" -> c.danger + "amrap" -> c.gold + else -> c.muted + } + val isNormal = typeKey == "normal" + val isTimeBased = trackingType.startsWith("duration") + val durationSec = set.durationSec ?: set.reps + val displayValue = if (isTimeBased) { + "${formatDurationShort(durationSec)}${if (set.weight > 0) " · ${set.weight}" else ""}" + } else { + "${if (set.weight > 0) formatWeightFromKg(set.weight, weightUnit) else "BW"} × ${set.reps.toCleanString()}" + } + + Column(Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // "SET N" label + Text( + "SET ${setIndex + 1}", + color = c.muted, + fontWeight = FontWeight.Bold, + fontSize = IronLogType.micro.fontSize.sp, + letterSpacing = IronLogType.micro.letterSpacing.sp, + modifier = Modifier.width(44.dp), + ) + + // Type badge — muted tap target for normal, colored for special types + Box( + Modifier + .background( + if (isNormal) c.faint else typeColor.copy(alpha = 0.10f), + RoundedCornerShape(IronLogRadius.sm.dp), + ) + .border( + 1.dp, + if (isNormal) c.cardBorder else typeColor.copy(alpha = 0.40f), + RoundedCornerShape(IronLogRadius.sm.dp), + ) + .clickable { + val next = SetTypes[(SetTypes.indexOf(typeKey).coerceAtLeast(0) + 1) % SetTypes.size] + dispatch(WorkoutAction.SetType(exIndex, setIndex, next)) + } + .padding(horizontal = 7.dp, vertical = 3.dp), + contentAlignment = Alignment.Center, + ) { + Text( + TypeLabel[typeKey] ?: typeKey.uppercase(), + color = if (isNormal) c.muted else typeColor, + fontWeight = FontWeight.Bold, + fontSize = IronLogType.micro.fontSize.sp, + letterSpacing = 0.3.sp, + ) + } + + // Weight × Reps + 1RM on one compact line + Column(Modifier.weight(1f)) { + Text( + displayValue, + color = c.text, + fontWeight = FontWeight.SemiBold, + fontSize = IronLogType.body.fontSize.sp, + maxLines = 1, + ) + if (set.orm > 0 && !isTimeBased) { + Text( + "~${formatWeightFromKg(set.orm, weightUnit)} 1RM", + color = c.muted, + fontSize = IronLogType.micro.fontSize.sp, + ) + } + } + + // Effort tracking + if (effortTracking == "rpe" || effortTracking == "both") { + SmallEffortField("RPE", set.rpe?.toString().orEmpty()) { text -> + dispatch(WorkoutAction.SetRpe(exIndex, setIndex, text.toDoubleOrNull())) + } + } + if (effortTracking == "rir" || effortTracking == "both") { + SmallEffortField("RIR", set.rir?.toString().orEmpty()) { text -> + dispatch(WorkoutAction.SetRir(exIndex, setIndex, text.toIntOrNull())) + } + } + + // Action icons — SVG, not emoji + IconButton( + onClick = { editOpen = false; noteOpen = !noteOpen }, + modifier = Modifier.size(36.dp), + ) { + Icon( + Icons.Outlined.ChatBubbleOutline, + contentDescription = "Note", + tint = if (noteOpen || !set.note.isNullOrBlank()) c.accent else c.muted, + modifier = Modifier.size(18.dp), + ) + } + IconButton( + onClick = { + editWeight = set.weight.toString() + editReps = set.reps.toString() + noteOpen = false + editOpen = !editOpen + }, + modifier = Modifier.size(36.dp), + ) { + Icon( + Icons.Outlined.Edit, + contentDescription = "Edit", + tint = if (editOpen) c.accent else c.muted, + modifier = Modifier.size(18.dp), + ) + } + IconButton( + onClick = { dispatch(WorkoutAction.DeleteSet(exIndex, setIndex)) }, + modifier = Modifier.size(36.dp), + ) { + Icon( + Icons.Outlined.Delete, + contentDescription = "Delete", + tint = c.muted, + modifier = Modifier.size(18.dp), + ) + } + } + + // Edit row + AnimatedVisibility(editOpen) { + Row( + Modifier.fillMaxWidth().padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Bottom, + ) { + OutlinedTextField( + editWeight, + { editWeight = it }, + label = { Text(weightUnit.uppercase()) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + editReps, + { editReps = it }, + label = { Text(if (isTimeBased) "SECONDS" else "REPS") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + Button( + onClick = { + dispatch( + WorkoutAction.UpdateSet( + exIndex, + setIndex, + editWeight.toDoubleOrNull() ?: 0.0, + editReps.toDoubleOrNull() ?: 0.0, + ), + ) + editOpen = false + }, + colors = ButtonDefaults.buttonColors(containerColor = c.accent), + ) { + Text("SAVE", color = c.textOnAccent, fontWeight = FontWeight(IronLogType.button.fontWeight)) + } + } + } + + // Note field + AnimatedVisibility(noteOpen) { + OutlinedTextField( + value = set.note.orEmpty(), + onValueChange = { dispatch(WorkoutAction.SetNote(exIndex, setIndex, it)) }, + placeholder = { Text("Set note...") }, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + minLines = 2, + ) + } + if (!noteOpen && !set.note.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + set.note.orEmpty(), + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + maxLines = 1, + modifier = Modifier.clickable { noteOpen = true }, + ) + } + } +} + +@Composable +private fun SmallEffortField(label: String, value: String, onChange: (String) -> Unit) { + val c = useTheme() + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(label, color = c.muted, fontWeight = FontWeight.Bold, fontSize = IronLogType.micro.fontSize.sp) + OutlinedTextField( + value, + onChange, + modifier = Modifier.width(52.dp), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + ) + } +} + +private fun Double.toCleanString(): String = + if (this % 1.0 == 0.0) this.toInt().toString() else this.toString() diff --git a/app/src/main/java/com/ironlog/app/ui/context/ActiveWorkoutBannerContext.kt b/app/src/main/java/com/ironlog/app/ui/context/ActiveWorkoutBannerContext.kt new file mode 100644 index 0000000..9995885 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/context/ActiveWorkoutBannerContext.kt @@ -0,0 +1,48 @@ +package com.ironlog.app.ui.context + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import com.ironlog.app.ui.state.WorkoutState + +/** Translation of ActiveWorkoutBannerContext.js. */ +data class ActiveWorkoutBanner( + val planIndex: Int? = null, + val dayIndex: Int? = null, + val dayName: String? = null, + val startTime: Long? = null, + val isDeload: Boolean = false, +) + +data class ActiveWorkoutBannerState( + val banner: ActiveWorkoutBanner? = null, + val setBanner: (ActiveWorkoutBanner?) -> Unit = {}, + val minimized: Boolean = false, + val setMinimized: (Boolean) -> Unit = {}, + val saveWorkoutState: (WorkoutState?) -> Unit = {}, + val getSavedWorkoutState: () -> WorkoutState? = { null }, + val clearSavedWorkoutState: () -> Unit = {}, +) + +val LocalActiveWorkoutBanner = staticCompositionLocalOf { ActiveWorkoutBannerState() } + +@Composable +fun ActiveWorkoutBannerProvider(content: @Composable () -> Unit) { + val banner = remember { mutableStateOf(null) } + val minimized = remember { mutableStateOf(false) } + val savedWorkoutState = remember { mutableStateOf(null) } + val holder = ActiveWorkoutBannerState( + banner = banner.value, + setBanner = { banner.value = it }, + minimized = minimized.value, + setMinimized = { minimized.value = it }, + saveWorkoutState = { savedWorkoutState.value = it }, + getSavedWorkoutState = { savedWorkoutState.value }, + clearSavedWorkoutState = { savedWorkoutState.value = null }, + ) + CompositionLocalProvider(LocalActiveWorkoutBanner provides holder, content = content) +} + +@Composable fun useActiveBanner(): ActiveWorkoutBannerState = LocalActiveWorkoutBanner.current diff --git a/app/src/main/java/com/ironlog/app/ui/context/GlassModeContext.kt b/app/src/main/java/com/ironlog/app/ui/context/GlassModeContext.kt new file mode 100644 index 0000000..2796b6a --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/context/GlassModeContext.kt @@ -0,0 +1,45 @@ +package com.ironlog.app.ui.context + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import com.ironlog.app.data.repository.SettingsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +const val GLASS_MODE_STORAGE_KEY = "tab_bar_glass_mode_v1" + +data class GlassModeState( + val glassMode: String = "frosted", + val setGlassMode: (String) -> Unit = {}, +) + +val LocalGlassMode = staticCompositionLocalOf { GlassModeState() } + +@Composable +fun GlassModeProvider(repository: SettingsRepository?, scope: CoroutineScope, content: @Composable () -> Unit) { + val state = remember { mutableStateOf("frosted") } + // Load the persisted glass mode on first composition. Without this the value is always + // "frosted" on app launch regardless of what the user last selected. + LaunchedEffect(repository) { + if (repository != null) { + val stored = withContext(Dispatchers.IO) { repository.getString(GLASS_MODE_STORAGE_KEY) } + if (stored != null) state.value = stored + } + } + val holder = GlassModeState( + glassMode = state.value, + setGlassMode = { mode -> + state.value = mode + scope.launch { repository?.setSetting(GLASS_MODE_STORAGE_KEY, mode, "string") } + } + ) + CompositionLocalProvider(LocalGlassMode provides holder, content = content) +} + +@Composable fun useGlassMode(): GlassModeState = LocalGlassMode.current diff --git a/app/src/main/java/com/ironlog/app/ui/context/ThemeContext.kt b/app/src/main/java/com/ironlog/app/ui/context/ThemeContext.kt new file mode 100644 index 0000000..2221543 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/context/ThemeContext.kt @@ -0,0 +1,59 @@ +package com.ironlog.app.ui.context + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.platform.LocalContext +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import com.ironlog.app.ui.theme.IronLogThemeTokens +import com.ironlog.app.ui.theme.IronLogThemes + +/** Translation of ThemeContext.js. Monet wallpaper epoch should be triggered by platform integration. */ +val LocalIronLogTheme = staticCompositionLocalOf { IronLogThemes.ObsidianSilver } + +@Composable +fun ThemeProvider(themeName: String = IronLogThemes.DEFAULT_THEME, content: @Composable () -> Unit) { + val context = LocalContext.current + val darkTheme = isSystemInDarkTheme() + val resolved = if (themeName == "monet" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val cs = if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + IronLogThemeTokens( + name = "MONET_DYNAMIC", + bg = cs.background, + card = cs.surface, + cardBorder = cs.outlineVariant, + surface = cs.surfaceContainerHigh, + accent = cs.primary, + accentSoft = cs.primary.copy(alpha = 0.2f), + accentBorder = cs.primary.copy(alpha = 0.45f), + text = cs.onSurface, + subtext = cs.onSurfaceVariant, + muted = cs.onSurfaceVariant.copy(alpha = 0.85f), + faint = cs.surfaceContainerHighest, + tabBg = cs.surfaceContainer, + gold = cs.tertiary, + textOnAccent = cs.onPrimary, + onCard = cs.onSurface, + ghostText = cs.onSurfaceVariant.copy(alpha = 0.65f), + success = cs.tertiary, + warning = cs.secondary, + danger = cs.error, + info = cs.secondary, + onDanger = cs.onError, + chartPrimary = cs.primary, + chartSecondary = cs.secondary, + chartTertiary = cs.tertiary, + ) + } else { + IronLogThemes.byName(themeName) + } + CompositionLocalProvider(LocalIronLogTheme provides resolved) { + content() + } +} + +@Composable +fun useTheme(): IronLogThemeTokens = LocalIronLogTheme.current diff --git a/app/src/main/java/com/ironlog/app/ui/context/ThemeRuntime.kt b/app/src/main/java/com/ironlog/app/ui/context/ThemeRuntime.kt new file mode 100644 index 0000000..3a4e05a --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/context/ThemeRuntime.kt @@ -0,0 +1,15 @@ +package com.ironlog.app.ui.context + +import com.ironlog.app.ui.theme.IronLogThemes +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object ThemeRuntime { + private val _themeName = MutableStateFlow(IronLogThemes.DEFAULT_THEME) + val themeName: StateFlow = _themeName.asStateFlow() + + fun setTheme(name: String) { + _themeName.value = name + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/model/UiModels.kt b/app/src/main/java/com/ironlog/app/ui/model/UiModels.kt new file mode 100644 index 0000000..b27f1ae --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/model/UiModels.kt @@ -0,0 +1,149 @@ +package com.ironlog.app.ui.model + +import androidx.compose.runtime.Immutable +import com.ironlog.app.data.model.LegacyExerciseShape +import kotlinx.serialization.Serializable + +// Flat UI models passed to Compose screens from ViewModels. +// @Immutable lets Compose skip recomposition when these objects haven't changed reference. +@Immutable +@Serializable +data class UiPlanExercise( + val id: String, + val exerciseId: String, + val name: String, + val sets: Int, + val reps: String, + val restSeconds: Int, + val supersetGroup: String = "", + val isWarmup: Boolean = false, + val notes: String = "", +) + +@Immutable +@Serializable +data class UiPlanDay( + val id: String, + val name: String, + val color: String = "#FF4500", + val exercises: List = emptyList(), +) + +@Immutable +@Serializable +data class UiPlan( + val id: String, + val name: String, + val goal: String = "General Fitness", + val description: String = "", + val isActive: Boolean = false, + val days: List = emptyList(), +) + +@Immutable +data class HistoryExerciseSet( + val id: String = "", + val weight: Double = 0.0, + val reps: Double = 0.0, + val type: String = "normal", + val rpe: Double? = null, + val rir: Double? = null, + val note: String? = null, + val restSeconds: Int = 0, +) + +@Immutable +data class HistoryExercise( + val id: String = "", + val exerciseId: String = "", + val name: String = "Unknown", + val sets: List = emptyList(), + val primaryMuscles: List = emptyList(), + val primaryMuscle: String? = null, + val equipment: String? = null, + val category: String? = null, + val supersetGroup: String? = null, + val note: String? = null, +) + +@Immutable +data class HistoryEntry( + val id: String, + val date: String, + val duration: Int = 0, + val rating: Double? = null, + val summaryText: String? = null, + val name: String = "Workout", + val dayName: String? = null, + val planDayUid: String? = null, + val imported: Boolean = false, + val exercises: List = emptyList(), +) { + val sets: Int get() = exercises.sumOf { it.sets.size } + val volume: Double get() = exercises.sumOf { ex -> ex.sets.filter { it.type != "warmup" }.sumOf { it.weight * it.reps } } +} + +@Immutable +data class ChartPoint(val value: Int, val label: String = "") + +@Immutable +data class PersonalBest( + val exerciseName: String, + val bestWeight: Double, + val estOneRm: Double, + // FIXED: 26 — date + previousOrm for enriched PB display + val date: String? = null, // ISO date string (yyyy-MM-dd) when PR was set + val previousOrm: Double? = null, // second-best est 1RM for trend delta +) + +@Immutable +data class StatsUiState( + val history: List = emptyList(), + val pb: Map = emptyMap(), + val personalBests: List = emptyList(), + val pbGroups: Map = emptyMap(), + val streak: Int = 0, + val totalSets: Int = 0, + val avgDurationMin: Int = 0, + val chartData: List = emptyList(), + val weightUnit: String = "kg", +) + +@Immutable +data class IronLogSettings( + val theme: String = com.ironlog.app.ui.theme.IronLogThemes.DEFAULT_THEME, + val weightUnit: String = "kg", + val hapticFeedback: Boolean = true, + val effortTracking: String = "off", + val defaultRestSeconds: Int = 90, + val defaultRestHeavySeconds: Int = 180, + val barWeightKg: Double = 20.0, + val weeklyGoalDays: Int = 4, + val goalMode: String = "hypertrophy", + val progressionStyle: String = "balanced", + val userName: String = "", + val performanceMode: String = "balanced", + /** "builtin" = existing TrainingIntelligenceEngine; "gemini_nano" = APEX ENGINE; "cloud_ai" = BYOK Cloud AI. */ + val intelligenceMode: String = "builtin", + val cloudAiBaseUrl: String = "", + val cloudAiModelName: String = "", + val cloudAiDisplayName: String = "", + val cloudAiProviderPreset: String = "", + val cloudAiApiFormat: String = "openai", +) + +@Immutable +data class AppDataState( + val initialized: Boolean = false, + /** True once the plan repository has emitted its first snapshot (even if empty). + * HomeScreen uses this to distinguish "still loading" from "no plans exist" — + * prevents the "No program selected" empty state from flashing on cold start. */ + val plansLoaded: Boolean = false, + val plans: List = emptyList(), + val history: List = emptyList(), + val pb: Map = emptyMap(), + val exerciseNotes: Map = emptyMap(), + val settings: IronLogSettings = IronLogSettings(), + val onboardingComplete: Boolean = false, + val exerciseIndex: List = emptyList(), +) diff --git a/app/src/main/java/com/ironlog/app/ui/screens/OnboardingScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/OnboardingScreen.kt new file mode 100644 index 0000000..0a72132 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/OnboardingScreen.kt @@ -0,0 +1,199 @@ +package com.ironlog.app.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ironlog.app.data.repository.PlanRepository +import com.ironlog.app.data.seed.toPlanObject +import com.ironlog.app.ui.screens.onboarding.buildOnboardingLedgerSnapshot +import com.ironlog.app.ui.screens.onboarding.onboardingPreviewStats +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig +import com.ironlog.app.ui.screens.onboarding.OnboardingDraft +import com.ironlog.app.ui.screens.onboarding.OnboardingViewModel +import com.ironlog.app.ui.screens.onboarding.steps.* +import kotlinx.coroutines.launch + +/** + * Root onboarding composable — 8-screen Ironlog setup flow. + * HorizontalPager with swipe navigation and page indicator. + * [onComplete] receives the completed [OnboardingDraft] for atomic save. + */ +@Composable +fun OnboardingScreen( + onComplete: suspend (OnboardingDraft) -> Unit, + vm: OnboardingViewModel = viewModel(), + planRepo: PlanRepository = PlanRepository(), +) { + val draft by vm.draft.collectAsState() + val seededSnapshot = remember(draft) { buildOnboardingLedgerSnapshot(draft) } + val pagerState = rememberPagerState(pageCount = { 10 }) + val scope = rememberCoroutineScope() + + fun advance() = scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark) + .statusBarsPadding(), + ) { + HorizontalPager( + state = pagerState, + userScrollEnabled = true, + modifier = Modifier + .fillMaxSize() + .padding(bottom = 40.dp), + ) { page -> + when (page) { + 0 -> Step1Awakening(onAdvance = { advance() }) + 1 -> Step2Registration( + userName = draft.userName, + onUserNameChange = vm::updateUserName, + onNext = { advance() }, + ) + 2 -> Step3Baseline( + yearOfBirth = draft.yearOfBirth, + bodyweightKg = draft.bodyweightKg, + trainingAgeMonths = draft.trainingAgeMonths, + historicalTrainingDaysPerWeek = draft.historicalTrainingDaysPerWeek, + hasPastTraining = draft.hasPastTraining, + hasGymAccess = draft.hasGymAccess, + pushups = draft.baselinePushups, + pullups = draft.baselinePullups, + benchKg = draft.baselineBenchKg, + latPulldownKg = draft.baselineLatPulldownKg, + mileRunSeconds = draft.baselineMileRunSeconds, + onYearOfBirthChange = vm::updateYearOfBirth, + onBodyweightChange = vm::updateBodyweightKg, + onTrainingAgeChange = vm::updateTrainingAgeMonths, + onHistoricalTrainingDaysPerWeekChange = vm::updateHistoricalTrainingDaysPerWeek, + onPastTrainingChange = vm::setHasPastTraining, + onGymAccessChange = vm::setHasGymAccess, + onPushupsChange = vm::updateBaselinePushups, + onPullupsChange = vm::updateBaselinePullups, + onBenchChange = vm::updateBaselineBenchKg, + onLatPulldownChange = vm::updateBaselineLatPulldownKg, + onMileRunChange = vm::updateBaselineMileRunSeconds, + seededGrade = seededSnapshot.grade.label, + seededStats = onboardingPreviewStats(seededSnapshot.stats), + onNext = { advance() }, + ) + 3 -> Step3Classification( + selectedProgressionStyle = draft.progressionStyle, + onSelect = { style, goal -> vm.setClassification(style, goal) }, + onNext = { advance() }, + ) + 4 -> Step4Quota( + selectedDayIndices = draft.selectedDayIndices, + onDayToggle = vm::toggleDayIndex, + weightUnit = draft.weightUnit, + onWeightUnitChange = vm::updateWeightUnit, + onNext = { advance() }, + ) + 5 -> Step5GoalMode( + selectedGoalMode = draft.goalMode, + onSelect = vm::setGoalMode, + onNext = { advance() }, + ) + 6 -> Step6AiAbilities( + cloudApiKey = draft.cloudAiApiKey, + cloudModelName = draft.cloudAiModelName, + cloudProvider = draft.cloudAiProviderPreset, + onApiKeyChange = vm::updateCloudApiKey, + onModelChange = vm::updateCloudModelName, + onProviderChange = { provider, model -> vm.updateCloudProvider(provider, model) }, + onNext = { advance() }, + onSkip = { advance() }, + ) + 7 -> Step7Permissions( + cameraGranted = draft.cameraGranted, + healthConnectGranted = draft.healthConnectGranted, + notificationsGranted = draft.notificationsGranted, + onCameraGranted = vm::setCameraGranted, + onHealthConnectGranted = vm::setHealthConnectGranted, + onNotificationsGranted = vm::setNotificationsGranted, + onNext = { advance() }, + ) + 8 -> Step8Complete( + userName = draft.userName, + weeklyGoalDays = draft.weeklyGoalDays, + weightUnit = draft.weightUnit, + goalMode = draft.goalMode, + progressionStyle = draft.progressionStyle, + intelligenceMode = draft.intelligenceMode, + healthConnectGranted = draft.healthConnectGranted, + notificationsGranted = draft.notificationsGranted, + qualifiedBadge = seededSnapshot.grade.label, + onStartTraining = { advance() }, + ) + 9 -> Step9ProgramSetup( + onSkip = { scope.launch { onComplete(draft) } }, + onApplyTemplate = { template -> + scope.launch { + runCatching { planRepo.importFullPlan(template.toPlanObject()) } + onComplete(draft) + } + }, + ) + } + } + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 12.dp), + ) { + Row( + modifier = Modifier + .height(44.dp) + .clip(CircleShape) + .background( + Brush.verticalGradient( + listOf( + OnboardingConfig.surfaceDark.copy(alpha = 0.82f), + OnboardingConfig.bgDark.copy(alpha = 0.70f), + ) + ) + ) + .border(1.dp, OnboardingConfig.cardBorder.copy(alpha = 0.90f), CircleShape) + .padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(pagerState.pageCount) { index -> + val selected = index == pagerState.currentPage + Box( + modifier = Modifier + .padding(horizontal = 4.dp) + .size(width = if (selected) 18.dp else 7.dp, height = 7.dp) + .clip(CircleShape) + .background( + if (selected) OnboardingConfig.accentBlue + else OnboardingConfig.cardBorder.copy(alpha = 0.95f), + ), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/SplashScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/SplashScreen.kt new file mode 100644 index 0000000..0a249e3 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/SplashScreen.kt @@ -0,0 +1,142 @@ +package com.ironlog.app.ui.screens + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.ironlog.app.ui.context.useTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +// Natural pixel dimensions of the source assets (same as RN reference) +private const val IRON_NAT_W = 330f +private const val LOG_NAT_W = 265f +private const val LOGO_NAT_H = 85f +private const val TOTAL_NAT_W = IRON_NAT_W + LOG_NAT_W // 595 + +@Composable +fun SplashScreen(onFinish: () -> Unit) { + val c = useTheme() + val sw = LocalConfiguration.current.screenWidthDp.toFloat() + + // Match RN sizing: total logo = 52 % of screen width + val scale = (sw * 0.52f) / TOTAL_NAT_W + val ironW = (IRON_NAT_W * scale).dp + val logW = (LOG_NAT_W * scale).dp + val logoH = (LOGO_NAT_H * scale).dp + + val ironX = remember { Animatable(-sw) } + val logX = remember { Animatable(sw) } + val shakeX = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + delay(250) + scope.launch { ironX.animateTo(0f, tween(700, easing = CubicBezierEasing(0.33f, 1f, 0.68f, 1f))) } + scope.launch { logX.animateTo(0f, tween(700, easing = CubicBezierEasing(0.33f, 1f, 0.68f, 1f))) } + delay(750) + listOf(-5f to 50, 5f to 60, -3f to 50, 2f to 50, 0f to 40).forEach { (x, ms) -> + shakeX.animateTo(x, tween(ms)) + } + delay(300) + onFinish() + } + + Box( + Modifier.fillMaxSize().background(c.bg), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Logo row + Row( + Modifier.graphicsLayer { translationX = shakeX.value }, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(id = com.ironlog.app.R.drawable.logo_iron), + contentDescription = "IRON", + modifier = Modifier + .graphicsLayer { translationX = ironX.value } + .width(ironW) + .height(logoH), + contentScale = ContentScale.Fit, + ) + Image( + painter = painterResource(id = com.ironlog.app.R.drawable.logo_log), + contentDescription = "LOG", + modifier = Modifier + .graphicsLayer { translationX = logX.value } + .width(logW) + .height(logoH), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(c.accent), + ) + } + + // Loading dots + LoadingDots() + } + } +} + +@Composable +private fun LoadingDots() { + val c = useTheme() + val infiniteTransition = rememberInfiniteTransition(label = "splashDots") + + // Three dots staggered by 160ms each + val dot1 by infiniteTransition.animateFloat( + initialValue = 0f, targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(480, delayMillis = 0, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), label = "dot1", + ) + val dot2 by infiniteTransition.animateFloat( + initialValue = 0f, targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(480, delayMillis = 160, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), label = "dot2", + ) + val dot3 by infiniteTransition.animateFloat( + initialValue = 0f, targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(480, delayMillis = 320, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), label = "dot3", + ) + + Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { + listOf(dot1, dot2, dot3).forEach { t -> + Box( + Modifier + .size(6.dp) + .background( + color = c.accent.copy(alpha = 0.30f + 0.70f * t), + shape = CircleShape, + ), + ) + } + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/body/BodyMapCanvas.kt b/app/src/main/java/com/ironlog/app/ui/screens/body/BodyMapCanvas.kt new file mode 100644 index 0000000..61f3fb5 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/body/BodyMapCanvas.kt @@ -0,0 +1,293 @@ +package com.ironlog.app.ui.screens.body + +import android.content.Context +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.asAndroidPath +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.vector.PathParser +import com.ironlog.app.ui.context.useTheme +import org.json.JSONObject + +// ── Shared types ───────────────────────────────────────────────────────────── + +internal data class ViewBox(val x: Float, val y: Float, val width: Float, val height: Float) +internal data class BodyPathPiece(val region: String, val path: Path) +internal data class BodySide( + val viewBox: ViewBox, + val pieces: List, + val alignmentBox: ViewBox = viewBox, +) +internal data class BodyMapDataset(val front: BodySide, val back: BodySide) + +internal fun String.toNormalizedPath(): Path = PathParser().parsePathString(this).toPath() + +// ── Transform helpers ───────────────────────────────────────────────────────── +// +// SVG viewBox → canvas mapping: +// canvas_x = offsetX + (svg_x - viewBox.x) * scale +// canvas_y = offsetY + (svg_y - viewBox.y) * scale +// +// Compose withTransform: translate(a, b) then scale(s) maps a draw-point P to: +// pixel = (a + P.x * s, b + P.y * s) +// +// Setting a = offsetX - viewBox.x * scale, b = offsetY - viewBox.y * scale, s = scale +// gives exactly the desired mapping. +// +// We use uniform (aspect-preserving) scale so the figures are never stretched. + +internal data class BodyTransform(val scale: Float, val offsetX: Float, val offsetY: Float) +internal enum class VerticalAnchor { TOP, CENTER, BOTTOM } + +private const val BODY_MAP_SCALE_FRACTION = 0.93f + +/** + * Computes a tight ViewBox that exactly encloses all path content (plus [padding] on each side). + * + * **Why this is needed:** the raw JSON viewBox includes large dead space at the top + * (FRONT body paths start at SVG y≈278, BACK at y≈290, but both viewBoxes start at y=95). + * Even after using a tight box, the FRONT/BACK heights differ slightly — which is why + * `BodyHalfCanvas` is rendered with the TOP anchor so heads always line up at the top of + * the canvas regardless of figure height differences. + * + * **Why PathMeasure instead of computeBounds:** Android's `Path.computeBounds()` returns + * the AABB including Bezier control points, which lie OUTSIDE the visible curve. That makes + * the box bigger than the actual visible content, and the inflation is different for FRONT + * vs BACK (different curve shapes), causing the very misalignment we're trying to fix. + * Sampling positions along the path via `PathMeasure` gives true visible bounds. + */ +internal fun computeTightViewBox( + pieces: List, + padding: Float = 12f, + samplesPerContour: Int = 512, +): ViewBox? { + if (pieces.isEmpty()) return null + var minX = Float.MAX_VALUE; var minY = Float.MAX_VALUE + var maxX = -Float.MAX_VALUE; var maxY = -Float.MAX_VALUE + val pm = android.graphics.PathMeasure() + val pos = FloatArray(2) + for (piece in pieces) { + val ap = piece.path.asAndroidPath() + pm.setPath(ap, false) + do { + val len = pm.length + if (len <= 0f) continue + for (i in 0..samplesPerContour) { + val d = (i / samplesPerContour.toFloat()) * len + if (pm.getPosTan(d, pos, null)) { + if (pos[0] < minX) minX = pos[0] + if (pos[1] < minY) minY = pos[1] + if (pos[0] > maxX) maxX = pos[0] + if (pos[1] > maxY) maxY = pos[1] + } + } + } while (pm.nextContour()) + } + if (!minX.isFinite() || !minY.isFinite() || !maxX.isFinite() || !maxY.isFinite()) return null + return ViewBox( + x = minX - padding, + y = minY - padding, + width = (maxX - minX) + padding * 2, + height = (maxY - minY) + padding * 2, + ) +} + +internal fun computeBodyTransform( + canvasW: Float, + canvasH: Float, + viewBox: ViewBox, + fitWidth: Float = viewBox.width, + fitHeight: Float = viewBox.height, + verticalAnchor: VerticalAnchor = VerticalAnchor.TOP, +): BodyTransform { + if (viewBox.width <= 0f || viewBox.height <= 0f || fitWidth <= 0f || fitHeight <= 0f) { + return BodyTransform(1f, 0f, 0f) + } + val scaleX = canvasW / fitWidth + val scaleY = canvasH / fitHeight + + // Fit each side into the same canonical box when fitWidth/fitHeight are shared. + val scale = minOf(scaleX, scaleY) * BODY_MAP_SCALE_FRACTION + + val offsetX = (canvasW - viewBox.width * scale) / 2f + + val offsetY = when (verticalAnchor) { + VerticalAnchor.TOP -> 0f + VerticalAnchor.CENTER -> (canvasH - viewBox.height * scale) / 2f + VerticalAnchor.BOTTOM -> (canvasH - viewBox.height * scale) + } + + return BodyTransform(scale, offsetX, offsetY) +} + +// ── Readiness normalisation (group keys → body-map region keys) ─────────────── + +internal fun buildDisplayReadiness(groupReadiness: Map): Map { + val push = groupReadiness["Push"] + val pull = groupReadiness["Pull"] + val shoulders = groupReadiness["Shoulders"] + val arms = groupReadiness["Arms"] + val core = groupReadiness["Core"] + val legs = groupReadiness["Legs"] + return mapOf( + "chest" to (push ?: 1.0), + "shoulders" to (shoulders ?: 1.0), + "rearDelts" to (shoulders ?: pull ?: 1.0), + "arms" to (arms ?: 1.0), + "core" to (core ?: 1.0), + "quads" to (legs ?: 1.0), + "hamstrings" to (legs ?: 1.0), + "calves" to (legs ?: 1.0), + "back" to (pull ?: 1.0), + ) +} + +// ── Asset loader ───────────────────────────────────────────────────────────── + +internal fun loadBodyMapDataset(context: Context): BodyMapDataset? = runCatching { + val raw = context.assets.open("ironlog/body_map_paths.json").bufferedReader().use { it.readText() } + val root = JSONObject(raw) + val viewBoxes = root.getJSONObject("VIEW_BOXES").getJSONObject("male") + val frontVb = viewBoxes.getJSONObject("front") + val backVb = viewBoxes.getJSONObject("back") + val muscleMap = root.getJSONObject("MUSCLE_MAP") + val front = root.getJSONObject("MALE_FRONT_PATHS") + val back = root.getJSONObject("MALE_BACK_PATHS") + + val microToMacro = mutableMapOf() + val groups = muscleMap.keys() + while (groups.hasNext()) { + val macro = groups.next() + val arr = muscleMap.getJSONArray(macro) + for (i in 0 until arr.length()) microToMacro[arr.getString(i)] = macro + } + + fun parseSide(sideObj: JSONObject, vbObj: JSONObject): BodySide { + val pieces = mutableListOf() + val keys = sideObj.keys() + while (keys.hasNext()) { + val slug = keys.next() + // Use empty string for unmapped slugs (body outline / silhouette paths). + // These get value == null in BodyHalfCanvas and are rendered as a subtle + // silhouette outline only — not filled with a recovery colour. + val region = microToMacro[slug] ?: "" + val node = sideObj.getJSONObject(slug) + val left = node.optJSONArray("left") + val right = node.optJSONArray("right") + if (left != null) for (i in 0 until left.length()) { val d = left.optString(i).trim(); if (d.isNotBlank()) pieces += BodyPathPiece(region, d.toNormalizedPath()) } + if (right != null) for (i in 0 until right.length()) { val d = right.optString(i).trim(); if (d.isNotBlank()) pieces += BodyPathPiece(region, d.toNormalizedPath()) } + } + // Use the tight bounding box of the actual path content instead of the raw + // JSON viewBox. The JSON viewBox includes dead space at the top/bottom that + // differs between front and back, causing visual misalignment between the two halves. + val rawVb = ViewBox( + x = vbObj.optDouble("x", 0.0).toFloat(), + y = vbObj.optDouble("y", 0.0).toFloat(), + width = vbObj.optDouble("width", 1.0).toFloat(), + height = vbObj.optDouble("height", 1.0).toFloat(), + ) + val tightVb = computeTightViewBox(pieces) ?: rawVb + val filledVb = computeTightViewBox(pieces.filter { it.region.isNotBlank() }) ?: tightVb + return BodySide(viewBox = tightVb, pieces = pieces, alignmentBox = filledVb) + } + + BodyMapDataset(front = parseSide(front, frontVb), back = parseSide(back, backVb)) +}.getOrNull() + +// ── Render-only canvas (no tap handling) ───────────────────────────────────── + +/** + * Draws one side of the body map (front or back) into whatever space it is + * given. Uses uniform (aspect-preserving) scaling centred in the canvas so + * figures are never stretched. The canvas itself is not clipped; a few stroked pixels + * may safely overflow the invisible half-canvas edge instead of cutting off fingers or feet. + * + * No tap detection — wrap in a Box with pointerInput if you need it. + */ +@Composable +internal fun BodyHalfCanvas( + pieces: List, + viewBox: ViewBox, + readiness: Map, + modifier: Modifier = Modifier, + fitWidth: Float = viewBox.width, + fitHeight: Float = viewBox.height, + horizontalShiftFraction: Float = 0f, + painFlags: Set = emptySet(), // GAP-20 + verticalAnchor: VerticalAnchor = VerticalAnchor.TOP, +) { + val c = useTheme() + // Pre-cache Android path conversions so asAndroidPath() is not called on every draw frame. + // The map is keyed on `pieces` identity — recomputed only when the piece list changes. + val androidPathCache = remember(pieces) { + pieces.associate { it to it.path.asAndroidPath() } + } + // Do NOT add fillMaxSize() here — the modifier already fixes the size via + // fillMaxWidth().aspectRatio(...). Adding fillMaxSize() re-expands to the + // parent's unconstrained max height, which shifts the body figure down-right. + Canvas(modifier) { + val t = computeBodyTransform( + canvasW = size.width, + canvasH = size.height, + viewBox = viewBox, + fitWidth = fitWidth, + fitHeight = fitHeight, + verticalAnchor = verticalAnchor, + ) + pieces.forEach { piece -> + val value = readiness[piece.region] + val isPainFlagged = piece.region in painFlags + withTransform({ + // Correct SVG-viewBox → canvas mapping (see comment at top of file) + val horizontalShiftPx = viewBox.width * t.scale * horizontalShiftFraction + translate( + left = t.offsetX - viewBox.x * t.scale + horizontalShiftPx, + top = t.offsetY - viewBox.y * t.scale, + ) + scale(scaleX = t.scale, scaleY = t.scale, pivot = Offset.Zero) + }) { + if (isPainFlagged) { + // GAP-20: Pain-flagged muscle — red fill + diagonal hatch overlay + val painColor = c.danger + drawPath(path = piece.path, color = painColor.copy(alpha = 0.55f)) + drawPath(path = piece.path, color = painColor, style = Stroke(width = 2f)) + // Diagonal hatch lines approximated with low-alpha strokes through the region. + // Use the pre-cached Android path to avoid per-frame allocation. + val androidPath = androidPathCache[piece] ?: piece.path.asAndroidPath() + val bounds = android.graphics.RectF() + androidPath.computeBounds(bounds, true) + val step = 8f + var y = bounds.top - bounds.width() + while (y < bounds.bottom + bounds.width()) { + val linePath = androidx.compose.ui.graphics.Path().apply { + moveTo(bounds.left, y) + lineTo(bounds.right, y + bounds.width()) + } + drawPath(linePath, painColor.copy(alpha = 0.25f), style = Stroke(1.2f)) + y += step + } + } else if (value == null) { + // Unmapped path (body silhouette / outline) — draw as a subtle + // stroke only so the body shape is visible without overriding + // the coloured muscle regions. + drawPath(path = piece.path, color = c.faint.copy(alpha = 0.50f), style = Stroke(width = 1.5f)) + } else { + val fill = when { + value > 0.9 -> c.success + value > 0.72 -> c.warning + else -> c.danger + } + drawPath(path = piece.path, color = fill.copy(alpha = 0.68f)) + drawPath(path = piece.path, color = c.cardBorder.copy(alpha = 0.40f), style = Stroke(width = 1.5f)) + } + } + } + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/body/BodyMeasurementsScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/body/BodyMeasurementsScreen.kt new file mode 100644 index 0000000..69017bc --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/body/BodyMeasurementsScreen.kt @@ -0,0 +1,824 @@ +package com.ironlog.app.ui.screens.body + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.ironlog.app.data.repository.BodyMeasurementInput +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.viewmodel.BodyMeasurementUiRow +import com.ironlog.app.ui.viewmodel.BodyWeightUiRow +import com.ironlog.app.util.convertUnitToKg +import com.ironlog.app.util.formatWeightFromKg +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.math.abs +import kotlin.math.roundToInt + +private data class MeasurementField(val key: String, val label: String, val unit: String) +private val MEASUREMENT_FIELDS = listOf( + MeasurementField("chest", "Chest", "cm"), + MeasurementField("waist", "Waist", "cm"), + MeasurementField("hips", "Hips", "cm"), + MeasurementField("shoulders", "Shoulders", "cm"), + MeasurementField("neck", "Neck", "cm"), + MeasurementField("arms", "Arms", "cm"), + MeasurementField("thighs", "Thighs", "cm"), + MeasurementField("calves", "Calves", "cm"), + MeasurementField("bodyFat", "Body Fat", "%"), +) + +private val INCREASE_GOOD = setOf("chest", "shoulders", "arms", "thighs", "calves") + +data class MeasurementDisplayRow( + val id: String, + val date: String, + val values: Map, +) + +fun parseMeasurementExtras(notes: String?): Map { + if (notes.isNullOrBlank() || !notes.startsWith("{")) return emptyMap() + return runCatching { + Json.parseToJsonElement(notes).jsonObject.mapNotNull { (k, v) -> + v.jsonPrimitive.doubleOrNull?.let { k to it } + }.toMap() + }.getOrDefault(emptyMap()) +} + +fun mapMeasurementRow(row: BodyMeasurementUiRow): MeasurementDisplayRow { + val extras = parseMeasurementExtras(row.notes) + return MeasurementDisplayRow( + id = row.id, + date = row.date, + values = mapOf( + "chest" to row.chest, + "waist" to row.waist, + "arms" to row.arm, + "thighs" to row.thigh, + "hips" to extras["hips"], + "shoulders" to extras["shoulders"], + "neck" to extras["neck"], + "calves" to extras["calves"], + "bodyFat" to extras["bodyFat"], + ), + ) +} + +private fun getCurrentAndDelta(fieldKey: String, rows: List): Pair { + val entries = rows.filter { it.values[fieldKey] != null } + val current = entries.firstOrNull()?.values?.get(fieldKey) + val prev = if (entries.size >= 2) entries[1].values[fieldKey] else null + val delta = if (current != null && prev != null) current - prev else null + return Pair(current, delta) +} + +private fun getMiniChartData(fieldKey: String, rows: List): List = + rows.filter { it.values[fieldKey] != null }.take(20).reversed().mapNotNull { it.values[fieldKey] } + +private fun formatBWHistoryDate(dateStr: String): String = runCatching { + DateTimeFormatter.ofPattern("dd MMM", Locale.UK) + .format(Instant.parse(dateStr).atZone(ZoneId.systemDefault())) +}.getOrElse { dateStr.substringBefore('T') } + +@Composable +fun BodyMeasurementsScreen( + measurements: List, + bodyWeight: List, + weightUnit: String = "kg", + hapticFeedback: Boolean = true, + onLogBodyWeight: suspend (BodyMeasurementInput) -> Unit, + onAddMeasurement: suspend (BodyMeasurementInput) -> Unit, +) { + val colors = useTheme() + var activeTab by remember { mutableIntStateOf(0) } + var weightInput by remember { mutableStateOf("") } + var bodyFatInput by remember { mutableStateOf("") } + var showAddMeasurements by remember { mutableStateOf(false) } + var alert by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val measurementRows = remember(measurements) { + measurements.filter { it.bodyweight == null }.map { mapMeasurementRow(it) } + } + val recentWeights = remember(bodyWeight) { bodyWeight.take(30).reversed() } + val weightDelta = remember(bodyWeight) { + val current = bodyWeight.firstOrNull() + if (current == null) { + null + } else { + // Find the entry whose date is closest to 7 calendar days before the most recent entry, + // within a ±3-day window. Using index-7 is wrong when the user logs infrequently. + val currentMs = runCatching { Instant.parse(current.date).toEpochMilli() }.getOrNull() + if (currentMs == null) { + null + } else { + val sevenDaysAgoMs = currentMs - 7L * 24 * 60 * 60 * 1000 + val threeDaysMs = 3L * 24 * 60 * 60 * 1000 + val weekOld = bodyWeight.drop(1) + .filter { row -> + runCatching { + val ms = Instant.parse(row.date).toEpochMilli() + kotlin.math.abs(ms - sevenDaysAgoMs) <= threeDaysMs + }.getOrDefault(false) + } + .minByOrNull { row -> + runCatching { + val ms = Instant.parse(row.date).toEpochMilli() + kotlin.math.abs(ms - sevenDaysAgoMs) + }.getOrDefault(Long.MAX_VALUE) + } + if (weekOld != null) current.weight - weekOld.weight else null + } + } + } + val currentBodyFat = remember(measurementRows) { measurementRows.firstOrNull()?.values?.get("bodyFat") } + + // GAP-28: expanded measurement full chart + var expandedMeasurement by remember { mutableStateOf(null) } + var expandedRange by remember { mutableStateOf("All") } + + // Goal state for measurement fields + var measurementGoals by remember { mutableStateOf>(emptyMap()) } + LaunchedEffect(Unit) { + val settingsRepo = SettingsRepository() + val goals = mutableMapOf() + MEASUREMENT_FIELDS.forEach { field -> + val g = settingsRepo.getString("measure_goal_${field.key}")?.toDoubleOrNull() + if (g != null) goals[field.key] = g + } + measurementGoals = goals + } + + Column(Modifier.fillMaxSize().background(colors.bg).statusBarsPadding()) { + // Tab bar + TabRow( + selectedTabIndex = activeTab, + containerColor = colors.bg, + contentColor = colors.accent, + ) { + listOf("WEIGHT", "MEASUREMENTS").forEachIndexed { idx, label -> + Tab( + selected = activeTab == idx, + onClick = { activeTab = idx }, + text = { + Text( + label, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 2.sp, + color = if (activeTab == idx) colors.accent else colors.muted, + ) + }, + ) + } + } + + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp).navigationBarsPadding(), + ) { + if (activeTab == 0) { + // ── WEIGHT TAB ─────────────────────────────────────────────────────────── + + // Input card + Column( + Modifier.fillMaxWidth() + .background(colors.card, RoundedCornerShape(14.dp)) + .border(1.dp, colors.cardBorder, RoundedCornerShape(14.dp)) + .padding(20.dp), + ) { + Text( + "TODAY'S WEIGHT (${weightUnit.uppercase()})", + color = colors.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + letterSpacing = 3.sp, + fontWeight = FontWeight.Bold, + ) + currentBodyFat?.let { bf -> + Text( + "Body fat: ${java.lang.String.format(Locale.US, "%.1f", bf)}%", + color = colors.subtext, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + Spacer(Modifier.height(12.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = weightInput, + onValueChange = { weightInput = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f), + singleLine = true, + placeholder = { Text("0.0") }, + textStyle = TextStyle(fontSize = IronLogType.metric.fontSize.sp, fontWeight = FontWeight(IronLogType.metric.fontWeight)), + ) + Button( + onClick = { + val inputWeight = weightInput.toDoubleOrNull() + val w = inputWeight?.let { convertUnitToKg(it, weightUnit, 2) } + if (inputWeight == null || w == null || w < 20.0 || w > 300.0) { + alert = "Enter a value between ${formatWeightFromKg(20.0, weightUnit)} and ${formatWeightFromKg(300.0, weightUnit)}." + return@Button + } + val bfValue = bodyFatInput.trim().toDoubleOrNull() + scope.launch { + onLogBodyWeight(BodyMeasurementInput(measuredAt = System.currentTimeMillis(), bodyweight = w)) + weightInput = "" + if (bfValue != null && bfValue > 0.0) { + onAddMeasurement(BodyMeasurementInput(measuredAt = System.currentTimeMillis(), notes = "{\"bodyFat\": $bfValue}")) + bodyFatInput = "" + } + } + }, + modifier = Modifier.height(56.dp), + ) { + Text("LOG", fontSize = IronLogType.body.fontSize.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = 2.sp) + } + } + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = bodyFatInput, + onValueChange = { bodyFatInput = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("0.0") }, + label = { Text("Body fat % (optional)") }, + ) + weightDelta?.let { delta -> + Spacer(Modifier.height(8.dp)) + val sign = if (delta > 0) "+" else "" + Text( + "$sign${formatWeightFromKg(abs(delta), weightUnit)} vs last week", + color = if (delta <= 0) colors.success else colors.warning, + fontSize = IronLogType.body.fontSize.sp, + ) + } + } + Spacer(Modifier.height(16.dp)) + + // Chart card + if (recentWeights.size >= 2) { + Column( + Modifier.fillMaxWidth() + .background(colors.card, RoundedCornerShape(14.dp)) + .border(1.dp, colors.cardBorder, RoundedCornerShape(14.dp)) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("LAST 30 DAYS", color = colors.muted, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 3.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + BMMiniWeightChart(recentWeights, weightUnit) + } + Spacer(Modifier.height(16.dp)) + } + + // History rows (last 14) + bodyWeight.take(14).forEach { e -> + Row( + Modifier.fillMaxWidth() + .border(width = 1.dp, color = colors.faint, shape = RoundedCornerShape(0.dp)) + .padding(vertical = 12.dp, horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(formatBWHistoryDate(e.date), color = colors.subtext, fontSize = IronLogType.body.fontSize.sp) + Text( + formatWeightFromKg(e.weight, weightUnit), + color = colors.text, + fontSize = IronLogType.section.fontSize.sp, + fontWeight = FontWeight.Bold, + ) + } + } + } else { + // ── MEASUREMENTS TAB ───────────────────────────────────────────────────── + + Button( + onClick = { showAddMeasurements = true }, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = RoundedCornerShape(10.dp), + ) { + Text("+ ADD MEASUREMENT", fontSize = IronLogType.body.fontSize.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = 2.sp) + } + Spacer(Modifier.height(20.dp)) + + // 2-column grid + MEASUREMENT_FIELDS.chunked(2).forEach { row -> + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + ) { + row.forEach { field -> + MeasurementFieldCard( + field = field, + rows = measurementRows, + goal = measurementGoals[field.key], + onGoalSaved = { newGoal -> + measurementGoals = measurementGoals.toMutableMap().also { map -> + if (newGoal != null) map[field.key] = newGoal else map.remove(field.key) + } + }, + onExpandTap = { + expandedRange = "All" + expandedMeasurement = field.key + }, + modifier = Modifier.weight(1f), + ) + } + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + } + + // Last logged + if (measurementRows.isNotEmpty()) { + Text( + "Last logged: ${formatBWHistoryDate(measurementRows.first().date)}", + color = colors.muted, + fontSize = IronLogType.meta.fontSize.sp, + letterSpacing = 1.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + } + } + } + + if (showAddMeasurements) { + AddMeasurementBottomSheet( + onDismiss = { showAddMeasurements = false }, + onSave = { values -> + val hasAny = MEASUREMENT_FIELDS.any { !values[it.key].isNullOrBlank() } + if (!hasAny) { + alert = "Enter at least one measurement value." + return@AddMeasurementBottomSheet + } + val parsed = values + .mapValues { it.value.toDoubleOrNull() } + .filterValues { it != null && it > 0.0 } + .mapValues { it.value!! } + val extrasEntries = listOf("hips", "shoulders", "neck", "calves", "bodyFat") + .mapNotNull { key -> parsed[key]?.let { key to JsonPrimitive(it) } } + .toMap() + val notes = if (extrasEntries.isNotEmpty()) JsonObject(extrasEntries).toString() else "" + scope.launch { + onAddMeasurement( + BodyMeasurementInput( + measuredAt = System.currentTimeMillis(), + bodyweight = null, + chest = parsed["chest"], + waist = parsed["waist"], + arm = parsed["arms"], + thigh = parsed["thighs"], + notes = notes, + ), + ) + showAddMeasurements = false + } + }, + ) + } + + alert?.let { + AlertDialog( + onDismissRequest = { alert = null }, + confirmButton = { TextButton({ alert = null }) { Text("OK") } }, + title = { Text(if (activeTab == 0) "Invalid weight" else "No data") }, + text = { Text(it) }, + ) + } + + // GAP-28: Full trend chart dialog for expanded measurement + expandedMeasurement?.let { fieldKey -> + val field = MEASUREMENT_FIELDS.find { it.key == fieldKey } + if (field != null) { + MeasurementTrendDialog( + field = field, + rows = measurementRows, + selectedRange = expandedRange, + onRangeChange = { expandedRange = it }, + onDismiss = { expandedMeasurement = null }, + ) + } + } +} + +@Composable +private fun MeasurementFieldCard( + field: MeasurementField, + rows: List, + goal: Double?, + onGoalSaved: (Double?) -> Unit, + onExpandTap: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + val colors = useTheme() + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + // Hoist SettingsRepository so it is created once per card, not on every keypress / focus change. + val settingsRepo = remember { SettingsRepository() } + val (current, delta) = remember(field.key, rows) { getCurrentAndDelta(field.key, rows) } + val chartData = remember(field.key, rows) { getMiniChartData(field.key, rows) } + val deltaColor = when { + delta == null || abs(delta) < 0.05 -> colors.subtext + INCREASE_GOOD.contains(field.key) -> if (delta > 0) colors.success else colors.warning + else -> if (delta < 0) colors.success else colors.warning + } + + // Goal input state — pre-fill from the persisted goal + var goalInput by remember(goal) { mutableStateOf(goal?.let { java.lang.String.format(Locale.US, "%.1f", it) } ?: "") } + + fun persistGoal(input: String) { + val parsed = input.trim().toDoubleOrNull() + scope.launch { + if (parsed != null && parsed > 0.0) { + settingsRepo.setString("measure_goal_${field.key}", parsed.toString()) + onGoalSaved(parsed) + } else if (input.trim().isEmpty()) { + settingsRepo.setString("measure_goal_${field.key}", "") + onGoalSaved(null) + } + } + } + + Column( + modifier + .background(colors.card, RoundedCornerShape(14.dp)) + .border(1.dp, colors.cardBorder, RoundedCornerShape(14.dp)) + .padding(12.dp) + .defaultMinSize(minHeight = 90.dp), + ) { + Text(field.label.uppercase(), color = colors.muted, fontSize = IronLogType.micro.fontSize.sp, letterSpacing = 2.sp) + Spacer(Modifier.height(6.dp)) + if (current == null) { + Text("—", color = colors.faint, fontSize = IronLogType.title.fontSize.sp, fontWeight = FontWeight.Light) + } else { + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + java.lang.String.format(Locale.US, "%.1f", current), + color = colors.text, + fontSize = IronLogType.title.fontSize.sp, + fontWeight = FontWeight.ExtraBold, + ) + Text(field.unit, color = colors.subtext, fontSize = IronLogType.meta.fontSize.sp) + delta?.let { + val sign = if (it > 0) "+" else "" + Text( + "$sign${java.lang.String.format(Locale.US, "%.1f", it)}", + color = deltaColor, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + if (chartData.size >= 2) { + Spacer(Modifier.height(6.dp)) + Box(Modifier.fillMaxWidth().clickable { onExpandTap() }) { + MeasurementMiniChart(chartData, colors.accent) + // Subtle expand hint + Text( + "Tap to expand", + color = colors.muted.copy(alpha = 0.5f), + fontSize = (IronLogType.micro.fontSize - 1).sp, + modifier = Modifier.align(Alignment.BottomEnd).padding(2.dp), + ) + } + } + } + + // ── Goal row ──────────────────────────────────────────────────────────── + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = goalInput, + onValueChange = { goalInput = it }, + singleLine = true, + placeholder = { Text("Goal", fontSize = IronLogType.meta.fontSize.sp, color = colors.faint) }, + label = { Text("Goal (${field.unit})", fontSize = IronLogType.micro.fontSize.sp, color = colors.muted) }, + textStyle = TextStyle(fontSize = IronLogType.meta.fontSize.sp, color = colors.text), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + persistGoal(goalInput) + }, + ), + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + if (!focusState.isFocused) { + persistGoal(goalInput) + } + }, + ) + + // Delta-to-goal label + if (current != null && goal != null) { + val deltaToGoal = goal - current + val withinFivePercent = goal != 0.0 && abs(deltaToGoal) / abs(goal) <= 0.05 + val goalLabelColor = if (withinFivePercent) colors.success else colors.muted + val sign = if (deltaToGoal > 0) "+" else "" + Spacer(Modifier.height(4.dp)) + Text( + "${sign}${deltaToGoal.roundToInt()} ${field.unit} to goal", + color = goalLabelColor, + fontSize = IronLogType.micro.fontSize.sp, + ) + } + } +} + +@Composable +private fun MeasurementMiniChart(data: List, accentColor: Color) { + // Need at least 2 points; a single point causes division-by-zero in the x-position formula. + if (data.size < 2) return + val minV = data.minOrNull() ?: 0.0 + val maxV = data.maxOrNull() ?: 1.0 + val range = (maxV - minV).takeIf { it != 0.0 } ?: 1.0 + Canvas(Modifier.fillMaxWidth().height(60.dp)) { + val pad = 6f + val pts = data.mapIndexed { i, v -> + val x = pad + (i.toFloat() / (data.size - 1)) * (size.width - pad * 2) + val y = size.height - pad - (((v - minV) / range).toFloat()) * (size.height - pad * 2) + Offset(x, y) + } + val baselineY = size.height - pad + // Gradient fill under the line + val fillPath = Path().apply { + moveTo(pts.first().x, baselineY) + pts.forEach { lineTo(it.x, it.y) } + lineTo(pts.last().x, baselineY) + close() + } + drawPath( + fillPath, + brush = Brush.verticalGradient( + colors = listOf(accentColor.copy(alpha = 0.25f), Color.Transparent), + startY = pad, + endY = baselineY, + ), + ) + val path = Path().apply { + moveTo(pts.first().x, pts.first().y) + pts.drop(1).forEach { lineTo(it.x, it.y) } + } + drawPath(path, accentColor, style = androidx.compose.ui.graphics.drawscope.Stroke(1.5f)) + drawCircle(accentColor, 3f, pts.last()) + } +} + +/** GAP-28: Full-size trend chart dialog for a single measurement field with date-range filter. */ +@Composable +private fun MeasurementTrendDialog( + field: MeasurementField, + rows: List, + selectedRange: String, + onRangeChange: (String) -> Unit, + onDismiss: () -> Unit, +) { + val c = useTheme() + val rangeOptions = listOf("30D", "90D", "1Y", "All") + val cutoffMs = remember(selectedRange) { + val now = System.currentTimeMillis() + when (selectedRange) { + "30D" -> now - 30L * 86_400_000L + "90D" -> now - 90L * 86_400_000L + "1Y" -> now - 365L * 86_400_000L + else -> 0L + } + } + val filteredData = remember(field.key, rows, cutoffMs) { + rows.filter { row -> + val rowMs = runCatching { java.time.Instant.parse(row.date).toEpochMilli() }.getOrDefault(0L) + rowMs >= cutoffMs && row.values[field.key] != null + }.reversed().mapNotNull { it.values[field.key] } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Column( + Modifier + .fillMaxWidth(0.96f) + .clip(RoundedCornerShape(16.dp)) + .background(c.card) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Header + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(field.label.uppercase(), color = c.accent, fontWeight = FontWeight.Black, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 2.sp) + androidx.compose.material3.TextButton(onClick = onDismiss) { Text("CLOSE", color = c.muted) } + } + + // Range filter chips + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + rangeOptions.forEach { opt -> + val selected = opt == selectedRange + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(if (selected) c.accent else c.surface) + .border(1.dp, if (selected) c.accent else c.cardBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { onRangeChange(opt) } + .padding(horizontal = 12.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + Text(opt, color = if (selected) c.textOnAccent else c.subtext, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Medium) + } + } + } + + // Full chart + if (filteredData.size >= 2) { + MeasurementMiniChart(filteredData, c.accent) + Text("${filteredData.size} data points", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } else { + Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) { + Text("Not enough data for selected range", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + } + } + } + } +} + +@Composable +private fun BMMiniWeightChart(rows: List, unit: String) { + val colors = useTheme() + if (rows.size < 2) return + val weights = rows.map { it.weight } + val minW = (weights.minOrNull() ?: 0.0) - 1.0 + val maxW = (weights.maxOrNull() ?: 1.0) + 1.0 + val range = (maxW - minW).takeIf { it != 0.0 } ?: 1.0 + Canvas(Modifier.fillMaxWidth().height(120.dp)) { + val pad = 20f + val pts = rows.mapIndexed { i, e -> + val x = pad + (i.toFloat() / (rows.size - 1)) * (size.width - pad * 2) + val y = size.height - pad - (((e.weight - minW) / range).toFloat()) * (size.height - pad * 2) + Offset(x, y) + } + val baselineY = size.height - pad + drawLine(colors.faint, Offset(pad, baselineY), Offset(size.width - pad, baselineY), strokeWidth = 1f) + // Gradient fill + val fillPath = Path().apply { + moveTo(pts.first().x, baselineY) + pts.forEach { lineTo(it.x, it.y) } + lineTo(pts.last().x, baselineY) + close() + } + drawPath( + fillPath, + brush = Brush.verticalGradient( + colors = listOf(colors.accent.copy(alpha = 0.28f), Color.Transparent), + startY = pad, + endY = baselineY, + ), + ) + val path = Path().apply { + moveTo(pts.first().x, pts.first().y) + pts.drop(1).forEach { lineTo(it.x, it.y) } + } + drawPath(path, colors.accent, style = androidx.compose.ui.graphics.drawscope.Stroke(2f)) + drawCircle(colors.accent, 4f, pts.last()) + } +} + +@Composable +private fun AddMeasurementBottomSheet( + onDismiss: () -> Unit, + onSave: (Map) -> Unit, +) { + val colors = useTheme() + val values = remember { mutableStateMapOf() } + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + Column( + Modifier + .fillMaxWidth() + .fillMaxHeight(0.9f) + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + .background(colors.card), + ) { + // Header + Row( + Modifier.fillMaxWidth() + .border(width = 1.dp, color = colors.faint, shape = RoundedCornerShape(0.dp)) + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "ADD MEASUREMENT", + color = colors.text, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 3.sp, + ) + Text( + "✕", + color = colors.muted, + fontSize = IronLogType.section.fontSize.sp, + modifier = Modifier.clickable(onClick = onDismiss).padding(4.dp), + ) + } + + // Field rows + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp), + ) { + MEASUREMENT_FIELDS.forEach { f -> + Row( + Modifier.fillMaxWidth() + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${f.label} (${f.unit})", + color = colors.subtext, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = values[f.key] ?: "", + onValueChange = { values[f.key] = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.width(100.dp), + singleLine = true, + placeholder = { Text("—", textAlign = TextAlign.End, modifier = Modifier.fillMaxWidth()) }, + textStyle = TextStyle( + fontSize = IronLogType.section.fontSize.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.End, + ), + ) + } + HorizontalDivider(color = colors.faint) + } + Spacer(Modifier.height(28.dp)) + Button( + onClick = { onSave(values) }, + modifier = Modifier.fillMaxWidth().height(50.dp), + shape = RoundedCornerShape(10.dp), + ) { + Text("SAVE", fontSize = IronLogType.body.fontSize.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = 2.sp) + } + Spacer(Modifier.height(40.dp)) + } + } + } + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/body/BodyWeightScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/body/BodyWeightScreen.kt new file mode 100644 index 0000000..5912a12 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/body/BodyWeightScreen.kt @@ -0,0 +1,481 @@ +package com.ironlog.app.ui.screens.body + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.data.repository.BodyMeasurementInput +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.viewmodel.BodyWeightUiRow +import com.ironlog.app.services.ShareService +import com.ironlog.app.util.* +import kotlinx.coroutines.launch +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import androidx.compose.ui.graphics.nativeCanvas +import java.util.Locale +import kotlin.math.abs + +private const val CHART_HEIGHT = 240f +private data class PlotPad(val top: Float = 20f, val right: Float = 30f, val bottom: Float = 36f, val left: Float = 42f) +private val PLOT = PlotPad() + +data class SummaryCard(val id: String, val label: String, val value: String, val raw: Double?) +fun formatWeightValue(value: Double?, unit: String): String = if (value == null || !value.isFinite()) "--" else java.lang.String.format(Locale.US, "%.1f %s", value, unit) +fun formatSigned(value: Double?, unit: String): String = if (value == null || !value.isFinite()) "--" else "${if (value > 0) "+" else ""}${java.lang.String.format(Locale.US, "%.1f %s", value, unit)}" +fun computeMovingAverage(entries: List, windowDays: Int = 7): List = entries.mapIndexed { i, entry -> + val cutoff = entry.timestamp - windowDays * 24L * 60L * 60L * 1000L + val window = entries.take(i + 1).filter { it.timestamp >= cutoff } + window.sumOf { it.weight } / window.size +} +fun summaryCards(summary: BodyWeightSummary, unit: String) = listOf( + SummaryCard("current", "Current Weight", formatWeightValue(summary.currentWeight, unit), summary.currentWeight), + SummaryCard("prev", "Change vs Previous", formatSigned(summary.changeFromPrevious, unit), summary.changeFromPrevious), + SummaryCard("trend", "Weekly Trend", formatSigned(summary.weeklyTrend, unit), summary.weeklyTrend), + SummaryCard("week", "This Week Change", formatSigned(summary.weekChange, unit), summary.weekChange), + SummaryCard("month", "This Month Change", formatSigned(summary.monthChange, unit), summary.monthChange), + SummaryCard("total", "Total Change", formatSigned(summary.totalChange, unit), summary.totalChange), +) + +@Composable +fun BodyWeightScreen( + bodyWeight: List, + weightUnit: String = "kg", + goalWeight: Double? = null, + hapticFeedback: Boolean = true, + onBack: () -> Unit = {}, + onLogBodyWeight: suspend (BodyMeasurementInput) -> Unit, + onDeleteBodyWeightEntry: suspend (String) -> Unit, + onSetGoalWeight: suspend (Double?) -> Unit = {}, +) { + val colors = useTheme() + val context = LocalContext.current + var weightInput by remember { mutableStateOf("") } + var goalInput by remember { mutableStateOf(goalWeight?.let { java.lang.String.format(Locale.US, "%.1f", it) } ?: "") } + var range by remember { mutableStateOf("30D") } + var error by remember { mutableStateOf(null) } + val allEntriesAsc = remember(bodyWeight) { normalizeBodyWeightEntries(bodyWeight.map { RawBodyWeightEntry(it.id, it.weight, it.date) }) } + val selectedEntriesAsc = remember(allEntriesAsc, range) { filterEntriesByRange(allEntriesAsc, range) } + val chartEntries = remember(selectedEntriesAsc) { downsampleSeries(selectedEntriesAsc) } + val summary = remember(allEntriesAsc, selectedEntriesAsc) { calculateBodyWeightSummary(allEntriesAsc, selectedEntriesAsc) } + val cards = remember(summary, weightUnit) { summaryCards(summary, weightUnit) } + val historyDesc = remember(allEntriesAsc) { allEntriesAsc.sortedByDescending { it.timestamp } } + val scope = rememberCoroutineScope() + val settingsRepo = remember { SettingsRepository() } + var pendingDeleteId by remember { mutableStateOf(null) } // GAP-09: delete confirmation + var heightCm by remember { mutableStateOf(null) } + var heightInput by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + val stored = settingsRepo.getString("user_height_cm")?.toDoubleOrNull() + heightCm = stored + if (stored != null) heightInput = java.lang.String.format(Locale.US, "%.0f", stored) + } + + // BMI: uses weight in kg and height in cm + val currentWeightKg = summary.currentWeight?.let { if (weightUnit == "lbs") it / 2.2046226218 else it } + val bmi = remember(currentWeightKg, heightCm) { + val w = currentWeightKg; val h = heightCm + if (w != null && h != null && h > 0) w / ((h / 100.0) * (h / 100.0)) else null + } + fun bmiCategory(b: Double) = when { + b < 18.5 -> "Underweight" + b < 25.0 -> "Normal" + b < 30.0 -> "Overweight" + else -> "Obese" + } + + // Sync goal input field when prop changes (e.g. initial load) + LaunchedEffect(goalWeight) { + if (goalInput.isEmpty()) goalInput = goalWeight?.let { java.lang.String.format(Locale.US, "%.1f", it) } ?: "" + } + + Column(Modifier.fillMaxSize().background(colors.bg).statusBarsPadding().verticalScroll(rememberScrollState()).padding(16.dp).navigationBarsPadding()) { + ScreenHeader(title = "BODY WEIGHT", onBack = onBack) + CardBlock("LOG BODY WEIGHT") { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = weightInput, + onValueChange = { weightInput = it }, + placeholder = { Text("0.0 $weightUnit") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f), + singleLine = true, + ) + Button(onClick = { + val value = weightInput.toDoubleOrNull() + // GAP-22: unit-aware validation range + val (minVal, maxVal) = if (weightUnit == "lbs") 44.0 to 880.0 else 20.0 to 400.0 + if (value == null || value < minVal || value > maxVal) { error = "Please enter a value between ${"%.0f".format(minVal)} and ${"%.0f".format(maxVal)} $weightUnit."; return@Button } + scope.launch { onLogBodyWeight(BodyMeasurementInput(measuredAt = System.currentTimeMillis(), bodyweight = value)); weightInput = "" } + }) { Text("LOG") } + } + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + OutlinedTextField( + value = goalInput, + onValueChange = { goalInput = it }, + placeholder = { Text("Goal ($weightUnit)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f), + singleLine = true, + label = { Text("Goal weight") }, + ) + Button(onClick = { + val g = goalInput.trim().toDoubleOrNull() + val isLbs = weightUnit.lowercase().trimEnd('s') == "lb" + val minGoal = if (isLbs) 44.0 else 20.0 + val maxGoal = if (isLbs) 880.0 else 400.0 + if (g != null && (g < minGoal || g > maxGoal)) { error = "Goal must be between ${minGoal.toInt()} and ${maxGoal.toInt()} $weightUnit."; return@Button } + scope.launch { onSetGoalWeight(g) } + }) { Text("SET") } + } + // GAP-13: predicted goal-reach date + val current = summary.currentWeight + val trend = summary.weeklyTrend + if (current != null && goalWeight != null && trend != null && trend != 0.0) { + val delta = goalWeight - current + val weeksToGoal = if ((delta > 0) == (trend > 0)) (abs(delta / trend)).toInt().coerceAtLeast(1) else null + Spacer(Modifier.height(6.dp)) + if (weeksToGoal != null) { + Text( + "At current rate: ~$weeksToGoal week${if (weeksToGoal == 1) "" else "s"} to goal", + color = colors.success, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, + ) + } else { + Text( + "Trend moving away from goal", + color = colors.warning, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, + ) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + BODY_WEIGHT_RANGES.forEach { option -> FilterChip(selected = option == range, onClick = { range = option }, label = { Text(option) }) } + } + Spacer(Modifier.height(12.dp)) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + cards.chunked(2).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + row.forEach { card -> SummaryCardView(card, Modifier.weight(1f)) } + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + } + } + // BMI card + CardBlock("BMI") { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = heightInput, + onValueChange = { heightInput = it }, + placeholder = { Text("Height (cm)") }, + label = { Text("Height (cm)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f), + singleLine = true, + ) + Button(onClick = { + val h = heightInput.trim().toDoubleOrNull() + if (h != null && h in 50.0..300.0) { + heightCm = h + scope.launch { settingsRepo.setString("user_height_cm", h.toString()) } + } + }) { Text("SET") } + } + if (bmi != null) { + Spacer(Modifier.height(12.dp)) + val cat = bmiCategory(bmi) + val bmiColor = when (cat) { + "Underweight" -> colors.warning + "Normal" -> colors.success + "Overweight" -> colors.warning + else -> colors.danger + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column { + Text("BMI", color = colors.muted, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 1.5.sp) + Text(java.lang.String.format(Locale.US, "%.1f", bmi), color = bmiColor, fontSize = IronLogType.title.fontSize.sp, fontWeight = FontWeight.Black) + } + Column { + Text("CATEGORY", color = colors.muted, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 1.5.sp) + Text(cat, color = bmiColor, fontSize = IronLogType.section.fontSize.sp, fontWeight = FontWeight.Bold) + } + } + } else { + Spacer(Modifier.height(8.dp)) + Text( + if (heightCm == null) "Enter your height to calculate BMI." else "Log a body weight entry to calculate BMI.", + color = colors.muted, + fontSize = IronLogType.body.fontSize.sp, + ) + } + } + TextButton(onClick = { + ShareService.shareMinimalCardImage( + context = context, + title = "Ironlog Bodyweight", + headline = formatWeightValue(summary.currentWeight, weightUnit), + metrics = listOf( + ShareService.ShareMetric("Weekly trend", formatSigned(summary.weeklyTrend, weightUnit)), + ShareService.ShareMetric("Month change", formatSigned(summary.monthChange, weightUnit)), + ShareService.ShareMetric("Total change", formatSigned(summary.totalChange, weightUnit)), + ), + ) + }) { Text("Share Progress") } + CardBlock("WEIGHT TREND ($range)") { WeightChart(chartEntries, weightUnit, goalWeight) } + CardBlock("HISTORY") { + if (historyDesc.isEmpty()) Text("No body weight entries logged yet.", color = colors.muted) + historyDesc.forEach { entry -> + Row(Modifier.fillMaxWidth().padding(vertical = 10.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Column { Text(java.lang.String.format(Locale.US, "%.1f %s", entry.weight, weightUnit), color = colors.text); Text(formatHistoryDate(entry.timestamp), color = colors.subtext, fontSize = IronLogType.meta.fontSize.sp) } + // GAP-09: confirm before deleting + Text("Delete", color = colors.muted, modifier = Modifier.clickable { pendingDeleteId = entry.id }) + } + } + } + } + error?.let { AlertDialog(onDismissRequest = { error = null }, confirmButton = { TextButton({ error = null }) { Text("OK") } }, title = { Text("Invalid weight") }, text = { Text(it) }) } + + // GAP-09: delete confirmation dialog + pendingDeleteId?.let { idToDelete -> + AlertDialog( + onDismissRequest = { pendingDeleteId = null }, + title = { Text("Delete entry?") }, + text = { Text("This body weight entry will be permanently removed. This cannot be undone.") }, + confirmButton = { + TextButton(onClick = { + val id = idToDelete + pendingDeleteId = null + scope.launch { onDeleteBodyWeightEntry(id) } + }) { Text("DELETE", color = colors.danger) } + }, + dismissButton = { TextButton(onClick = { pendingDeleteId = null }) { Text("CANCEL") } }, + ) + } +} + +@Composable private fun CardBlock(title: String, content: @Composable ColumnScope.() -> Unit) { + val colors = useTheme() + Column(Modifier.fillMaxWidth().padding(bottom = 12.dp).background(colors.card, RoundedCornerShape(16.dp)).border(1.dp, colors.cardBorder, RoundedCornerShape(16.dp)).padding(16.dp)) { + Text(title, color = colors.muted, fontSize = IronLogType.eyebrow.fontSize.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = 2.2.sp) + Spacer(Modifier.height(12.dp)); content() + } +} +@Composable private fun SummaryCardView(card: SummaryCard, modifier: Modifier = Modifier) { + val colors = useTheme() + val valueColor = if (card.raw == null || abs(card.raw) < 0.05) colors.subtext else if (card.raw < 0) colors.success else colors.warning + Column(modifier.background(colors.card, RoundedCornerShape(14.dp)).border(1.dp, colors.cardBorder, RoundedCornerShape(14.dp)).padding(12.dp)) { Text(card.label, color = colors.muted, fontSize = IronLogType.eyebrow.fontSize.sp); Text(card.value, color = valueColor, fontSize = IronLogType.section.fontSize.sp, fontWeight = FontWeight.Black) } +} +private fun weightCatmullRomPath(pts: List, tension: Float = 0.5f): Path { + val path = Path() + if (pts.isEmpty()) return path + path.moveTo(pts[0].x, pts[0].y) + for (i in 0 until pts.size - 1) { + val p0 = pts.getOrElse(i - 1) { pts[i] } + val p1 = pts[i]; val p2 = pts[i + 1] + val p3 = pts.getOrElse(i + 2) { pts[i + 1] } + val cp1x = p1.x + (p2.x - p0.x) * tension / 3f; val cp1y = p1.y + (p2.y - p0.y) * tension / 3f + val cp2x = p2.x - (p3.x - p1.x) * tension / 3f; val cp2y = p2.y - (p3.y - p1.y) * tension / 3f + path.cubicTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y) + } + return path +} + +private fun weightCatmullRomFillPath(pts: List, baselineY: Float, tension: Float = 0.5f): Path { + val path = Path() + if (pts.isEmpty()) return path + path.moveTo(pts[0].x, baselineY); path.lineTo(pts[0].x, pts[0].y) + for (i in 0 until pts.size - 1) { + val p0 = pts.getOrElse(i - 1) { pts[i] } + val p1 = pts[i]; val p2 = pts[i + 1] + val p3 = pts.getOrElse(i + 2) { pts[i + 1] } + val cp1x = p1.x + (p2.x - p0.x) * tension / 3f; val cp1y = p1.y + (p2.y - p0.y) * tension / 3f + val cp2x = p2.x - (p3.x - p1.x) * tension / 3f; val cp2y = p2.y - (p3.y - p1.y) * tension / 3f + path.cubicTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y) + } + path.lineTo(pts.last().x, baselineY); path.close() + return path +} + +@Composable private fun WeightChart(entries: List, unit: String, goalWeight: Double? = null) { + val colors = useTheme() + if (entries.isEmpty()) { Box(Modifier.fillMaxWidth().height(218.dp), contentAlignment = androidx.compose.ui.Alignment.Center) { Text("No body weight entries for this range yet.", color = colors.muted) }; return } + val ma = remember(entries) { computeMovingAverage(entries) } + + fun colorToArgb(c: androidx.compose.ui.graphics.Color) = android.graphics.Color.argb( + (c.alpha * 255).toInt(), (c.red * 255).toInt(), (c.green * 255).toInt(), (c.blue * 255).toInt() + ) + // Hoist Paint objects out of the draw lambda — allocating them inside + // Canvas.DrawScope causes a new object per frame which triggers garbage collection pressure. + val axisPaint = remember(colors) { + android.graphics.Paint().apply { + textSize = 28f + isAntiAlias = true + typeface = android.graphics.Typeface.DEFAULT + } + }.also { it.color = colorToArgb(colors.muted) } + // goalLabelPaint is mutated (color + align) inside the draw lambda — create once, update properties. + val goalLabelPaint = remember { + android.graphics.Paint().apply { + textSize = 24f + isAntiAlias = true + } + } + // formatChartAxisDate is a pure function — no need to remember it + + + Canvas(Modifier.fillMaxWidth().height(CHART_HEIGHT.dp)) { + val plotWidth = size.width - PLOT.left - PLOT.right + val plotHeight = size.height - PLOT.top - PLOT.bottom + val values = entries.map { it.weight } + ma + listOfNotNull(goalWeight) + val min = values.minOrNull() ?: 0.0 + val max = values.maxOrNull() ?: 1.0 + val baseRange = max - min + val pad = if (baseRange < 0.6) 0.4 else baseRange * 0.15 + val yMin = min - pad + val yMax = max + pad + val yRange = (yMax - yMin).takeIf { it != 0.0 } ?: 1.0 + val points = entries.mapIndexed { i, e -> + val x = if (entries.size == 1) PLOT.left + plotWidth / 2 else PLOT.left + (i.toFloat() / (entries.size - 1)) * plotWidth + val y = PLOT.top + (((yMax - e.weight) / yRange).toFloat()) * plotHeight + Offset(x, y) + } + val baselineY = PLOT.top + plotHeight + + // Horizontal grid lines + val yTickCount = 4 + for (i in 0..yTickCount) { + val frac = i.toFloat() / yTickCount + val lineY = PLOT.top + plotHeight * (1f - frac) + drawLine(colors.faint, Offset(PLOT.left, lineY), Offset(size.width - PLOT.right, lineY), 1f) + } + + // Gradient fill under Catmull-Rom curve + drawPath( + weightCatmullRomFillPath(points, baselineY), + brush = Brush.verticalGradient( + colors = listOf(colors.accent.copy(alpha = 0.28f), Color.Transparent), + startY = PLOT.top, + endY = baselineY, + ), + ) + // Smooth Catmull-Rom main line + drawPath(weightCatmullRomPath(points), colors.accent, style = androidx.compose.ui.graphics.drawscope.Stroke(width = 3f)) + points.forEach { drawCircle(colors.accent, radius = 4.5f, center = it) } + // 7-day moving average line (dashed, also curved) + if (ma.size >= 3) { + val maPoints = ma.mapIndexed { i, v -> + val x = if (entries.size == 1) PLOT.left + plotWidth / 2 else PLOT.left + (i.toFloat() / (entries.size - 1)) * plotWidth + val y = PLOT.top + (((yMax - v) / yRange).toFloat()) * plotHeight + Offset(x, y) + } + drawPath(weightCatmullRomPath(maPoints), colors.accent.copy(alpha = 0.55f), style = androidx.compose.ui.graphics.drawscope.Stroke(width = 1.5f, pathEffect = androidx.compose.ui.graphics.PathEffect.dashPathEffect(floatArrayOf(8f, 4f)))) + } + + // Goal weight horizontal dashed line — always drawn, clamped to chart area if out of range + if (goalWeight != null) { + val inRange = goalWeight >= yMin && goalWeight <= yMax + val clampedGoal = goalWeight.coerceIn(yMin, yMax) + val goalY = PLOT.top + (((yMax - clampedGoal) / yRange).toFloat()) * plotHeight + val lineAlpha = if (inRange) 0.85f else 0.50f + drawLine( + color = colors.success.copy(alpha = lineAlpha), + start = Offset(PLOT.left, goalY), + end = Offset(size.width - PLOT.right, goalY), + strokeWidth = 2f, + pathEffect = androidx.compose.ui.graphics.PathEffect.dashPathEffect(floatArrayOf(10f, 6f)), + ) + // Mutate the remembered goalLabelPaint rather than allocating a new Paint per draw frame. + goalLabelPaint.color = android.graphics.Color.argb( + (colors.success.alpha * 255 * lineAlpha).toInt(), + (colors.success.red * 255).toInt(), + (colors.success.green * 255).toInt(), + (colors.success.blue * 255).toInt(), + ) + goalLabelPaint.textAlign = android.graphics.Paint.Align.RIGHT + val suffix = when { + goalWeight > yMax -> " ▲" + goalWeight < yMin -> " ▼" + else -> "" + } + drawContext.canvas.nativeCanvas.drawText( + java.lang.String.format(Locale.US, "Goal: %.1f%s", goalWeight, suffix), + size.width - PLOT.right - 4f, + goalY - 6f, + goalLabelPaint, + ) + } + + // Y-axis labels (right-aligned against the left margin) + val nc = drawContext.canvas.nativeCanvas + axisPaint.textAlign = android.graphics.Paint.Align.RIGHT + for (i in 0..yTickCount) { + val frac = i.toFloat() / yTickCount + val value = yMin + (yMax - yMin) * frac + val lineY = PLOT.top + plotHeight * (1f - frac) + nc.drawText( + String.format(Locale.US, "%.1f", value), + PLOT.left - 6f, + lineY + axisPaint.textSize / 3f, + axisPaint, + ) + } + + // X-axis date labels: first, middle (if ≥3 pts), last + axisPaint.textAlign = android.graphics.Paint.Align.LEFT + val xLabelY = size.height - 2f + if (entries.isNotEmpty()) { + nc.drawText( + formatChartAxisDate(Instant.fromEpochMilliseconds(entries.first().timestamp).toLocalDateTime(TimeZone.currentSystemDefault()).date), + points.first().x, + xLabelY, + axisPaint, + ) + } + if (entries.size >= 3) { + val midIdx = entries.size / 2 + axisPaint.textAlign = android.graphics.Paint.Align.CENTER + nc.drawText( + formatChartAxisDate(Instant.fromEpochMilliseconds(entries[midIdx].timestamp).toLocalDateTime(TimeZone.currentSystemDefault()).date), + points[midIdx].x, + xLabelY, + axisPaint, + ) + } + if (entries.size >= 2) { + axisPaint.textAlign = android.graphics.Paint.Align.RIGHT + nc.drawText( + formatChartAxisDate(Instant.fromEpochMilliseconds(entries.last().timestamp).toLocalDateTime(TimeZone.currentSystemDefault()).date), + points.last().x, + xLabelY, + axisPaint, + ) + } + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/body/ProgressPhotoCompareLogic.kt b/app/src/main/java/com/ironlog/app/ui/screens/body/ProgressPhotoCompareLogic.kt new file mode 100644 index 0000000..4b1aaf8 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/body/ProgressPhotoCompareLogic.kt @@ -0,0 +1,42 @@ +package com.ironlog.app.ui.screens.body + +import com.ironlog.app.data.objectbox.ProgressPhotoEntity +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +internal fun updateDateCompareSelection( + selectedA: LocalDate?, + selectedB: LocalDate?, + tappedDate: LocalDate, +): Pair = when { + selectedA == null -> tappedDate to null + selectedA == tappedDate && selectedB == null -> null to null + selectedA == tappedDate -> selectedB to null + selectedB == tappedDate -> selectedA to null + selectedB == null -> selectedA to tappedDate + else -> tappedDate to null +} + +internal fun resolveDateComparePhotos( + rows: List, + selectedA: LocalDate?, + selectedB: LocalDate?, +): Pair = + latestPhotoForDate(rows, selectedA) to latestPhotoForDate(rows, selectedB) + +internal fun progressPhotoLocalDate( + takenAt: Long, + zoneId: ZoneId = ZoneId.systemDefault(), +): LocalDate = Instant.ofEpochMilli(takenAt).atZone(zoneId).toLocalDate() + +private fun latestPhotoForDate( + rows: List, + date: LocalDate?, +): ProgressPhotoEntity? { + if (date == null) return null + return rows + .asSequence() + .filter { progressPhotoLocalDate(it.takenAt) == date } + .maxByOrNull { it.takenAt } +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/body/ProgressPhotosScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/body/ProgressPhotosScreen.kt new file mode 100644 index 0000000..0c26d09 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/body/ProgressPhotosScreen.kt @@ -0,0 +1,1005 @@ +package com.ironlog.app.ui.screens.body + +import android.content.Intent +import android.net.Uri +import androidx.core.content.FileProvider +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ChevronLeft +import androidx.compose.material.icons.outlined.ChevronRight +import androidx.compose.material.icons.outlined.FitnessCenter +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.data.objectbox.ProgressPhotoEntity +import com.ironlog.app.data.objectbox.ProgressPhotoEntity_ +import com.ironlog.app.data.objectbox.newUid +import com.ironlog.app.services.ShareService +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import java.time.LocalDate +import java.time.YearMonth +import java.time.ZoneId +import java.time.format.TextStyle as JTextStyle +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import coil3.compose.AsyncImage +import androidx.compose.material3.OutlinedTextField +import androidx.compose.ui.text.style.TextOverflow + +@Composable +@OptIn(ExperimentalFoundationApi::class) +fun ProgressPhotosScreen(onBack: () -> Unit = {}) { + val c = useTheme() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val photoBox = remember { ObjectBox.store.boxFor(ProgressPhotoEntity::class.java) } + var rows by remember { mutableStateOf>(emptyList()) } + var pendingCameraUri by remember { mutableStateOf(null) } + var pendingPhotoDate by remember { mutableStateOf(null) } + var showAddPhotoDialog by remember { mutableStateOf(null) } + var selectedA by remember { mutableStateOf(null) } + var selectedB by remember { mutableStateOf(null) } + var compareMix by remember { mutableStateOf(0.5f) } + var showViewer by remember { mutableStateOf(false) } + var viewerStartIndex by remember { mutableStateOf(0) } + var showClearAllConfirm by remember { mutableStateOf(false) } + var isExportingZip by remember { mutableStateOf(false) } + // Calendar state + var calendarMonth by remember { mutableStateOf(YearMonth.now()) } + var calendarFilter by remember { mutableStateOf(null) } + var calendarCompareMode by remember { mutableStateOf(false) } + var compareDateA by remember { mutableStateOf(null) } + var compareDateB by remember { mutableStateOf(null) } + + suspend fun refresh() { + val loaded = withContext(Dispatchers.IO) { + photoBox.query().orderDesc(ProgressPhotoEntity_.takenAt).build().use { it.find() } + } + withContext(Dispatchers.Main) { rows = loaded } + } + + LaunchedEffect(Unit) { refresh() } + + val dateComparePhotos = remember(rows, compareDateA, compareDateB) { + resolveDateComparePhotos( + rows = rows, + selectedA = compareDateA, + selectedB = compareDateB, + ) + } + val activeSelectedA = if (calendarCompareMode) dateComparePhotos.first else selectedA + val activeSelectedB = if (calendarCompareMode) dateComparePhotos.second else selectedB + val activeCompareDateSet = remember(compareDateA, compareDateB) { + buildSet { + compareDateA?.let { add(it) } + compareDateB?.let { add(it) } + } + } + + val photoPicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + val dateForPhoto = pendingPhotoDate + pendingPhotoDate = null + scope.launch(Dispatchers.IO) { + runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } + val now = System.currentTimeMillis() + val takenAt = dateForPhoto + ?.atStartOfDay(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli() + ?: now + // Compress/resize to max 1080px before storing + val savedUri = compressAndSavePhoto(context, uri) ?: uri + photoBox.put( + ProgressPhotoEntity().apply { + uid = newUid() + fileUri = savedUri.toString() + this.takenAt = takenAt + createdAt = now + updatedAt = now + notes = "" + }, + ) + refresh() + } + } + + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture(), + ) { success -> + val uri = pendingCameraUri + if (!success || uri == null) { pendingCameraUri = null; pendingPhotoDate = null; return@rememberLauncherForActivityResult } + // Capture before launch — pendingPhotoDate is nulled on the main thread immediately + // after scope.launch returns, so reading it inside Dispatchers.IO would race. + val capturedDate = pendingPhotoDate + pendingCameraUri = null + pendingPhotoDate = null + scope.launch(Dispatchers.IO) { + val now = System.currentTimeMillis() + val takenAt = capturedDate + ?.atStartOfDay(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli() + ?: now + // Compress/resize camera JPEG to max 1080px and save to app private storage + val savedUri = compressAndSavePhoto(context, uri) ?: uri + if (savedUri != uri) deleteOwnedProgressPhoto(context, uri) + photoBox.put( + ProgressPhotoEntity().apply { + uid = newUid() + fileUri = savedUri.toString() + this.takenAt = takenAt + createdAt = now + updatedAt = now + notes = "" + }, + ) + refresh() + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize().background(c.bg).statusBarsPadding().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { ScreenHeader(title = "PROGRESS PHOTOS", onBack = onBack) } + + // ── Month calendar ──────────────────────────────────────────────────── + item { + val photoDates = remember(rows) { + rows.map { r -> progressPhotoLocalDate(r.takenAt) }.toSet() + } + PhotoCalendar( + month = calendarMonth, + photoDates = photoDates, + selectedDate = calendarFilter, + compareDateA = compareDateA, + compareDateB = compareDateB, + compareMode = calendarCompareMode, + onPrevMonth = { calendarMonth = calendarMonth.minusMonths(1) }, + onNextMonth = { calendarMonth = calendarMonth.plusMonths(1) }, + onDayClick = { day -> + if (calendarCompareMode) { + val updated = updateDateCompareSelection(compareDateA, compareDateB, day) + compareDateA = updated.first + compareDateB = updated.second + } else { + calendarFilter = if (calendarFilter == day) null else day + } + }, + onAddForDay = { day -> showAddPhotoDialog = day }, + ) + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { + calendarCompareMode = false + compareDateA = null + compareDateB = null + }, + modifier = Modifier.weight(1f), + ) { Text("Filter day", color = if (!calendarCompareMode) c.accent else c.muted) } + TextButton( + onClick = { + calendarCompareMode = true + calendarFilter = null + selectedA = null + selectedB = null + }, + modifier = Modifier.weight(1f), + ) { Text("Compare dates", color = if (calendarCompareMode) c.accent else c.muted) } + } + } + if (calendarCompareMode) { + item { + Text( + when { + compareDateA == null -> "Tap a date with a photo to set compare slot A." + compareDateB == null -> "Tap a second date to build the side-by-side compare." + else -> "Comparing the latest photo from each selected date." + }, + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } + + // ── Add photo buttons ───────────────────────────────────────────────── + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { + val out = createProgressPhotoUri(context) + pendingCameraUri = out + cameraLauncher.launch(out) + }, + modifier = Modifier.weight(1f), + ) { Text("Camera") } + Button( + onClick = { photoPicker.launch(arrayOf("image/*")) }, + modifier = Modifier.weight(1f), + ) { Text("From Device") } + } + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { + val latest = rows.firstOrNull() ?: return@Button + val uri = runCatching { Uri.parse(latest.fileUri) }.getOrNull() ?: return@Button + context.startActivity( + Intent(Intent.ACTION_SEND).apply { + type = "image/*" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "Ironlog progress photo") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }, + ) + }, + modifier = Modifier.weight(1f), + ) { Text("Share Latest") } + Button( + enabled = !isExportingZip && rows.isNotEmpty(), + onClick = { + scope.launch { + isExportingZip = true + runCatching { + val zipUri = buildProgressPhotosZip(context, rows) + if (zipUri != null) { + context.startActivity( + Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, zipUri) + putExtra(Intent.EXTRA_SUBJECT, "Ironlog progress photos") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }, + ) + } + } + isExportingZip = false + } + }, + modifier = Modifier.weight(1f), + ) { + if (isExportingZip) CircularProgressIndicator(modifier = androidx.compose.ui.Modifier.size(16.dp), strokeWidth = 2.dp) + else Text("Export All") + } + Button( + onClick = { showClearAllConfirm = true }, + modifier = Modifier.weight(1f), + ) { Text("Clear All") } + } + } + + // ── Compare action bar ──────────────────────────────────────────────── + if (activeSelectedA != null || activeSelectedB != null || compareDateA != null || compareDateB != null) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(c.accentSoft) + .border(1.dp, c.accentBorder, RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Slot A + Column(Modifier.weight(1f)) { + Text("A", color = c.accent, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight.ExtraBold) + Text( + when { + calendarCompareMode && compareDateA != null -> "${compareDateA} ${activeSelectedA?.let { formatTakenAt(it.takenAt) } ?: ""}".trim() + else -> activeSelectedA?.let { formatTakenAt(it.takenAt) } ?: "Tap photo to select" + }, + color = if (activeSelectedA != null || compareDateA != null) c.text else c.muted, + fontSize = IronLogType.meta.fontSize.sp, + maxLines = 1, + ) + } + Text("vs", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + // Slot B + Column(Modifier.weight(1f)) { + Text("B", color = c.accent, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight.ExtraBold) + Text( + when { + calendarCompareMode && compareDateB != null -> "${compareDateB} ${activeSelectedB?.let { formatTakenAt(it.takenAt) } ?: ""}".trim() + else -> activeSelectedB?.let { formatTakenAt(it.takenAt) } ?: "Tap second photo" + }, + color = if (activeSelectedB != null || compareDateB != null) c.text else c.muted, + fontSize = IronLogType.meta.fontSize.sp, + maxLines = 1, + ) + } + // Compare CTA + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (activeSelectedB != null) c.accent else c.faint) + .clickable(enabled = activeSelectedB != null) { + showViewer = true + viewerStartIndex = rows.indexOfFirst { it.uid == activeSelectedA?.uid }.coerceAtLeast(0) + } + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "COMPARE", + color = if (activeSelectedB != null) c.textOnAccent else c.muted, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = FontWeight.ExtraBold, + ) + } + } + } + } + + val displayRows = if (calendarCompareMode && activeCompareDateSet.isNotEmpty()) { + rows.filter { r -> progressPhotoLocalDate(r.takenAt) in activeCompareDateSet } + } else if (calendarFilter != null) { + rows.filter { r -> + progressPhotoLocalDate(r.takenAt) == calendarFilter + } + } else rows + + if (displayRows.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + when { + calendarCompareMode && activeCompareDateSet.isNotEmpty() -> "No photos found on the selected compare dates." + calendarFilter != null -> "No photos on $calendarFilter" + else -> "No progress photos yet.\nTap Camera or From Device to add one." + }, + color = c.muted, + fontSize = IronLogType.body.fontSize.sp, + ) + } + } + } else { + items(displayRows, key = { it.uid }) { row -> + val isA = activeSelectedA?.uid == row.uid + val isB = activeSelectedB?.uid == row.uid + val isSelected = isA || isB + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) c.accent else c.cardBorder, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + if (calendarCompareMode) { + calendarCompareMode = false + compareDateA = null + compareDateB = null + } + when { + isA -> selectedA = null + isB -> selectedB = null + selectedA == null -> selectedA = row + selectedB == null -> selectedB = row + else -> { selectedA = row; selectedB = null } + } + } + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // ── Thumbnail ───────────────────────────────────────── + Box( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(8.dp)) + .background(c.faint), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = row.fileUri.takeIf { it.isNotBlank() }?.let { Uri.parse(it) }, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + // A / B badge + if (isA || isB) { + Box( + modifier = Modifier + .align(Alignment.TopStart) + .padding(4.dp) + .size(20.dp) + .clip(CircleShape) + .background(c.accent), + contentAlignment = Alignment.Center, + ) { + Text( + if (isA) "A" else "B", + color = c.textOnAccent, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = FontWeight.ExtraBold, + ) + } + } + } + + // ── Info + actions ──────────────────────────────────── + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + formatTakenAt(row.takenAt), + color = c.text, + fontWeight = FontWeight.SemiBold, + fontSize = IronLogType.body.fontSize.sp, + ) + if (row.notes.isNotBlank() && row.notes != "Imported from gallery" && row.notes != "Captured from camera") { + Text( + row.notes, + color = c.muted, + fontSize = IronLogType.micro.fontSize.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + if (isSelected) if (isA) "Selected as A" else "Selected as B" + else "Tap to select for compare", + color = if (isSelected) c.accent else c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton( + onClick = { + if (calendarCompareMode) { + calendarCompareMode = false + compareDateA = null + compareDateB = null + } + viewerStartIndex = rows.indexOfFirst { it.uid == row.uid }.coerceAtLeast(0) + if (selectedA == null) selectedA = row + showViewer = true + }, + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 4.dp), + ) { Text("View", fontSize = IronLogType.meta.fontSize.sp) } + TextButton( + onClick = { + val uid = row.uid + if (selectedA?.uid == uid) selectedA = null + if (selectedB?.uid == uid) selectedB = null + if (compareDateA != null && progressPhotoLocalDate(row.takenAt) == compareDateA) compareDateA = null + if (compareDateB != null && progressPhotoLocalDate(row.takenAt) == compareDateB) compareDateB = null + // Single coroutine so refresh runs after remove completes. + scope.launch(Dispatchers.IO) { + photoBox.remove(row) + refresh() + } + }, + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 4.dp), + ) { Text("Delete", color = c.danger, fontSize = IronLogType.meta.fontSize.sp) } + } + } + } + } + } + } + // Bottom scroll clearance + item { Spacer(Modifier.height(16.dp).navigationBarsPadding()) } + } + + if (showViewer && activeSelectedA != null) { + val pagerState = rememberPagerState( + initialPage = viewerStartIndex.coerceIn(0, (rows.size - 1).coerceAtLeast(0)), + pageCount = { rows.size.coerceAtLeast(1) }, + ) + // Full-screen overlay viewer + Box( + modifier = Modifier + .fillMaxSize() + .background(c.bg.copy(alpha = 0.97f)), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + if (activeSelectedB != null) "BEFORE / AFTER" else "PHOTO VIEWER", + color = c.accent, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 2.sp, + ) + TextButton(onClick = { showViewer = false }) { + Text("CLOSE", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Bold) + } + } + + if (activeSelectedB == null) { + // Single photo pager + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(16.dp)), + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + val row = rows.getOrNull(page) + AsyncImage( + model = row?.fileUri?.takeIf { it.isNotBlank() }?.let { Uri.parse(it) }, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + } + val currentPhoto = rows.getOrNull(pagerState.currentPage) + Text( + "${pagerState.currentPage + 1} / ${rows.size} • ${currentPhoto?.let { formatTakenAt(it.takenAt) } ?: ""}", + color = c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + ) + var notesDraft by remember(currentPhoto?.uid) { mutableStateOf(currentPhoto?.notes.orEmpty()) } + OutlinedTextField( + value = notesDraft, + onValueChange = { notesDraft = it }, + label = { Text("Notes", color = c.muted) }, + placeholder = { Text("Add a note for this photo…", color = c.muted, fontSize = IronLogType.body.fontSize.sp) }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 4, + ) + if (notesDraft != currentPhoto?.notes.orEmpty()) { + Button( + onClick = { + val photo = currentPhoto ?: return@Button + scope.launch(Dispatchers.IO) { + photo.notes = notesDraft + photo.updatedAt = System.currentTimeMillis() + photoBox.put(photo) + refresh() + } + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("SAVE NOTE") } + } + } else { + // Before / after compare with slider + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(16.dp)) + .background(c.card), + ) { + AsyncImage( + model = activeSelectedA?.fileUri?.takeIf { it.isNotBlank() }?.let { Uri.parse(it) }, + contentDescription = "Before", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + AsyncImage( + model = activeSelectedB?.fileUri?.takeIf { it.isNotBlank() }?.let { Uri.parse(it) }, + contentDescription = "After", + modifier = Modifier + .fillMaxSize() + .drawWithContent { + val revealWidth = size.width * compareMix + clipRect(left = 0f, top = 0f, right = revealWidth, bottom = size.height) { + this@drawWithContent.drawContent() + } + }, + contentScale = ContentScale.Crop, + ) + // A / B labels + Row( + modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Box( + Modifier + .clip(RoundedCornerShape(6.dp)) + .background(c.bg.copy(alpha = 0.75f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + // GAP-08: slider right → more B (After) visible; labels corrected + ) { Text("◀ A", color = c.text, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Bold) } + Box( + Modifier + .clip(RoundedCornerShape(6.dp)) + .background(c.bg.copy(alpha = 0.75f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { Text("B ▶", color = c.text, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Bold) } + } + } + Slider(value = compareMix, onValueChange = { compareMix = it }) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("B: ${activeSelectedB?.let { formatTakenAt(it.takenAt) }}", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + Text("A: ${activeSelectedA?.let { formatTakenAt(it.takenAt) }}", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + } + } + + // Add-photo-for-day dialog (empty calendar day tap) + showAddPhotoDialog?.let { dayToAdd -> + val fmt = java.time.format.DateTimeFormatter.ofPattern("d MMM yyyy") + androidx.compose.material3.AlertDialog( + onDismissRequest = { showAddPhotoDialog = null }, + title = { Text("Add photo for ${fmt.format(dayToAdd)}") }, + text = { Text("Choose how to add a photo for this date.") }, + confirmButton = { + TextButton(onClick = { + showAddPhotoDialog = null + pendingPhotoDate = dayToAdd + val out = createProgressPhotoUri(context) + pendingCameraUri = out + cameraLauncher.launch(out) + }) { Text("Camera") } + }, + dismissButton = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = { + showAddPhotoDialog = null + pendingPhotoDate = dayToAdd + photoPicker.launch(arrayOf("image/*")) + }) { Text("Gallery") } + TextButton(onClick = { showAddPhotoDialog = null }) { Text("Cancel") } + } + }, + ) + } + + if (showClearAllConfirm) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { showClearAllConfirm = false }, + title = { Text("Clear all progress photos?") }, + text = { Text("This deletes all saved progress photos permanently.") }, + confirmButton = { + TextButton(onClick = { + showClearAllConfirm = false + scope.launch(Dispatchers.IO) { + // Collect file URIs before removing from DB + val fileUris = runCatching { + photoBox.query().build().find().mapNotNull { it.fileUri } + }.getOrElse { emptyList() } + photoBox.removeAll() + // Delete underlying files from storage. + // URIs are content:// from FileProvider — .path returns the provider's + // virtual path, not a real filesystem path. Reconstruct from filesDir. + fileUris.forEach { uriStr -> + runCatching { + val uri = android.net.Uri.parse(uriStr) + when (uri.scheme) { + "file" -> java.io.File(uri.path!!).delete() + else -> { + // Last path segment is the filename; files live in + // context.filesDir/progress_photos/ + val filename = uri.lastPathSegment + if (filename != null) { + java.io.File(context.filesDir, "progress_photos/$filename").delete() + } + } + } + } + } + selectedA = null + selectedB = null + compareDateA = null + compareDateB = null + refresh() + } + }) { Text("Delete all") } + }, + dismissButton = { + TextButton(onClick = { showClearAllConfirm = false }) { Text("Cancel") } + }, + ) + } +} + +@Composable +private fun PhotoCalendar( + month: YearMonth, + photoDates: Set, + selectedDate: LocalDate?, + compareDateA: LocalDate?, + compareDateB: LocalDate?, + compareMode: Boolean, + onPrevMonth: () -> Unit, + onNextMonth: () -> Unit, + onDayClick: (LocalDate) -> Unit, + onAddForDay: (LocalDate) -> Unit = {}, +) { + val c = useTheme() + val today = LocalDate.now() + val firstOfMonth = month.atDay(1) + // Start week on Monday (ISO): Monday=1 … Sunday=7; offset so col 0 = Monday + val startOffset = (firstOfMonth.dayOfWeek.value - 1) + val daysInMonth = month.lengthOfMonth() + val totalCells = startOffset + daysInMonth + val rows = (totalCells + 6) / 7 // ceiling + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(c.card) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // ── Month navigation ───────────────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + IconButton(onClick = onPrevMonth) { + Icon(Icons.Outlined.ChevronLeft, contentDescription = "Previous month", tint = c.muted, modifier = Modifier.size(20.dp)) + } + Text( + "${month.month.getDisplayName(JTextStyle.FULL, Locale.getDefault())} ${month.year}", + color = c.text, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + fontSize = IronLogType.section.fontSize.sp, + ) + IconButton(onClick = onNextMonth) { + Icon(Icons.Outlined.ChevronRight, contentDescription = "Next month", tint = c.muted, modifier = Modifier.size(20.dp)) + } + } + + // ── Day-of-week headers ────────────────────────────────────────────── + Row(modifier = Modifier.fillMaxWidth()) { + listOf("M","T","W","T","F","S","S").forEach { lbl -> + Box(modifier = Modifier.weight(1f), contentAlignment = androidx.compose.ui.Alignment.Center) { + Text(lbl, color = c.muted, fontSize = IronLogType.micro.fontSize.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Medium) + } + } + } + + // ── Calendar grid ──────────────────────────────────────────────────── + repeat(rows) { rowIdx -> + Row(modifier = Modifier.fillMaxWidth()) { + repeat(7) { colIdx -> + val cellIdx = rowIdx * 7 + colIdx + val dayNum = cellIdx - startOffset + 1 + Box( + modifier = Modifier.weight(1f), + contentAlignment = androidx.compose.ui.Alignment.Center, + ) { + if (dayNum in 1..daysInMonth) { + val date = month.atDay(dayNum) + val hasPhoto = date in photoDates + val isSelected = date == selectedDate + val isCompareA = date == compareDateA + val isCompareB = date == compareDateB + val isToday = date == today + Box( + modifier = Modifier + .aspectRatio(1f) + .padding(2.dp) + .clip(CircleShape) + .background( + when { + isSelected -> c.accent + isCompareA -> c.accent + isCompareB -> c.accentSoft + hasPhoto -> c.accentSoft + else -> androidx.compose.ui.graphics.Color.Transparent + } + ) + .then( + if ((isToday && !isSelected && !isCompareA) || isCompareB) + Modifier.border(1.dp, c.accentBorder, CircleShape) + else Modifier + ) + .clickable { if (hasPhoto) onDayClick(date) else onAddForDay(date) }, + contentAlignment = androidx.compose.ui.Alignment.Center, + ) { + Text( + "$dayNum", + color = when { + isSelected -> c.textOnAccent + isCompareA -> c.textOnAccent + hasPhoto -> c.accent + else -> c.subtext + }, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = if (hasPhoto || isToday) + androidx.compose.ui.text.font.FontWeight.Bold + else androidx.compose.ui.text.font.FontWeight.Normal, + ) + } + } + } + } + } + } + + // ── Legend ──────────────────────────────────────────────────────────── + if (photoDates.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Box(Modifier.size(8.dp).clip(CircleShape).background(c.accentSoft).border(1.dp, c.accent, CircleShape)) + Text( + if (compareMode) "has photo · tap two dates to compare" + else "has photo · tap to filter", + color = c.muted, + fontSize = IronLogType.micro.fontSize.sp, + ) + } + } + } +} + +private fun createProgressPhotoUri(context: android.content.Context): Uri { + val dir = File(context.filesDir, "progress_photos").apply { mkdirs() } + val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val file = File(dir, "progress_$stamp.jpg") + return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) +} + +private fun deleteOwnedProgressPhoto(context: android.content.Context, uri: Uri) { + if (uri.authority != "${context.packageName}.fileprovider") return + val filename = uri.lastPathSegment?.substringAfterLast('/') ?: return + File(context.filesDir, "progress_photos/$filename").takeIf { it.isFile }?.delete() +} + +private fun formatTakenAt(millis: Long): String { + val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + return fmt.format(Date(millis)) +} + +/** Reads [sourceUri], downscales to [maxDim]px on the longest side, re-saves as JPEG at [quality]% in app storage. Returns the saved file Uri. */ +private suspend fun compressAndSavePhoto( + context: android.content.Context, + sourceUri: Uri, + maxDim: Int = 1080, + quality: Int = 85, +): Uri? = withContext(Dispatchers.IO) { + runCatching { + val photoDir = File(context.filesDir, "progress_photos").also { it.mkdirs() } + val outFile = File(photoDir, "photo_${System.currentTimeMillis()}.jpg") + + // Decode bounds first to compute sample size + val opts = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(sourceUri)?.use { android.graphics.BitmapFactory.decodeStream(it, null, opts) } + val rawW = opts.outWidth; val rawH = opts.outHeight + if (rawW <= 0 || rawH <= 0) return@runCatching null + + // Sample size: power-of-2 that brings longest dim <= maxDim*2 (decode smaller) + val longest = maxOf(rawW, rawH) + var sample = 1 + while (longest / (sample * 2) > maxDim) sample *= 2 + val decodeOpts = android.graphics.BitmapFactory.Options().apply { inSampleSize = sample } + val decoded = context.contentResolver.openInputStream(sourceUri)?.use { android.graphics.BitmapFactory.decodeStream(it, null, decodeOpts) } + ?: return@runCatching null + + // Scale down to maxDim if still larger + val bmp = if (maxOf(decoded.width, decoded.height) > maxDim) { + val scale = maxDim.toFloat() / maxOf(decoded.width, decoded.height) + val w = (decoded.width * scale).toInt(); val h = (decoded.height * scale).toInt() + val scaled = android.graphics.Bitmap.createScaledBitmap(decoded, w, h, true) + decoded.recycle() + scaled + } else decoded + + FileOutputStream(outFile).use { bmp.compress(android.graphics.Bitmap.CompressFormat.JPEG, quality, it) } + bmp.recycle() + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", outFile) + }.getOrNull() +} + +private suspend fun buildProgressPhotosZip( + context: android.content.Context, + rows: List, +): Uri? = withContext(Dispatchers.IO) { + runCatching { + val cacheDir = File(context.cacheDir, "photo_exports").also { it.mkdirs() } + val zipFile = File(cacheDir, "ironlog_progress_photos.zip") + val dateFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US) + ZipOutputStream(FileOutputStream(zipFile)).use { zos -> + rows.forEachIndexed { idx, row -> + val entryName = "photo_${dateFmt.format(Date(row.takenAt))}_${idx + 1}.jpg" + runCatching { + val uri = Uri.parse(row.fileUri) + context.contentResolver.openInputStream(uri)?.use { input -> + zos.putNextEntry(ZipEntry(entryName)) + input.copyTo(zos) + zos.closeEntry() + } + } + } + } + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", zipFile) + }.getOrNull() +} + + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/gamification/StatusWindowScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/gamification/StatusWindowScreen.kt new file mode 100644 index 0000000..d838ead --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/gamification/StatusWindowScreen.kt @@ -0,0 +1,657 @@ +// app/src/main/java/com/ironlog/app/ui/screens/StatusWindowScreen.kt +package com.ironlog.app.ui.screens.gamification + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.ironlog.app.assets.ForgeFoxExpression +import com.ironlog.app.domain.gamification.IronGrade +import com.ironlog.app.domain.gamification.IronGradeGate +import com.ironlog.app.domain.gamification.IronLedgerStats +import com.ironlog.app.ui.components.IronGradeBadge +import com.ironlog.app.ui.components.ironGradeColor +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.viewmodel.GamificationUiState +import com.ironlog.app.ui.viewmodel.XpLogEntry + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StatusWindowScreen( + state: GamificationUiState, + onBack: () -> Unit, + onRecoveryCircuitTap: () -> Unit, +) { + val rankColor = ironGradeColor(state.rank) + var showGradeBrowser by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Iron Ledger", fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Rank badge + level + item { + GradeBadgeSection( + rank = state.rank, + rankColor = rankColor, + level = state.level, + title = state.activeTitle, + integrityScore = state.integrityScore, + onBadgeClick = { showGradeBrowser = true }, + ) + } + + item { + DailyProofSection( + state = state, + onRecoveryCircuitTap = onRecoveryCircuitTap, + ) + } + + // XP progress bar + item { + XpProgressSection( + totalXp = state.totalXp, + xpInLevel = state.xpInLevel, + xpForNextLevel = state.xpForNextLevel, + level = state.level, + ) + } + + item { + XpLogSection(logs = state.xpLogs) + } + + // Streak + item { + StreakSection( + streakWeeks = state.streakWeeks, + onRecoveryCircuitTap = onRecoveryCircuitTap, + ) + } + + item { + NextGradeSection(state.nextGradeLabel, state.nextGradeGates, state.totalXp) + } + + item { + TrainingSignalsSection(stats = state.ledgerStats) + } + + item { + BadgeShelf( + unlockedBadges = state.unlockedBadges, + onOpenBrowser = { showGradeBrowser = true }, + ) + } + + item { Spacer(Modifier.height(16.dp).navigationBarsPadding()) } + } + } + + if (showGradeBrowser) { + GradeBrowserDialog( + currentRank = state.rank, + onDismiss = { showGradeBrowser = false }, + ) + } +} + +@Composable +private fun DailyProofSection( + state: GamificationUiState, + onRecoveryCircuitTap: () -> Unit, +) { + val accent = when (state.dailyProofStatus) { + com.ironlog.app.domain.gamification.DailyProofStatus.PROOF_LOGGED -> MaterialTheme.colorScheme.primary + com.ironlog.app.domain.gamification.DailyProofStatus.TRAIN_TODAY -> MaterialTheme.colorScheme.tertiary + com.ironlog.app.domain.gamification.DailyProofStatus.AT_RISK -> Color(0xFFE57373) + com.ironlog.app.domain.gamification.DailyProofStatus.RECOVER_SMART -> Color(0xFF7EC8B8) + com.ironlog.app.domain.gamification.DailyProofStatus.ACTIVE_WORKOUT -> Color(0xFFB39DDB) + com.ironlog.app.domain.gamification.DailyProofStatus.SETUP -> MaterialTheme.colorScheme.secondary + } + + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Surface( + shape = RoundedCornerShape(999.dp), + color = accent.copy(alpha = 0.14f), + border = BorderStroke(1.dp, accent.copy(alpha = 0.38f)), + ) { + Text( + text = "${state.dailyStreakDays} day streak", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + color = accent, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.ExtraBold, + ) + } + Text( + text = state.dailyProofHeadline, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = state.dailyProofDetail, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Surface( + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + text = state.dailyProofPrimaryActionLabel, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + } + if (!state.latestBadgeTitle.isNullOrBlank()) { + Surface( + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = state.latestBadgeTitle, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + ) + } + } + } + TextButton(onClick = onRecoveryCircuitTap) { + Text("Recovery circuit") + } + } + Image( + painter = painterResource(ForgeFoxExpression.fromId(state.foxExpressionId).drawableRes), + contentDescription = state.dailyProofHeadline, + modifier = Modifier.size(132.dp), + contentScale = ContentScale.Fit, + ) + } + } +} + +@Composable +private fun GradeBadgeSection( + rank: String, + rankColor: Color, + level: Int, + title: String, + integrityScore: Double, + onBadgeClick: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp), + ) { + IronGradeBadge( + rank = rank, + accent = rankColor, + modifier = Modifier + .size(108.dp) + .clickable(onClick = onBadgeClick), + ) + Spacer(Modifier.height(8.dp)) + Surface( + shape = RoundedCornerShape(999.dp), + color = rankColor.copy(alpha = 0.16f), + border = BorderStroke(1.dp, rankColor.copy(alpha = 0.52f)), + ) { + Text( + text = "$rank Grade", + modifier = Modifier.padding(horizontal = 14.dp, vertical = 5.dp), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.ExtraBold, + color = rankColor, + ) + } + Spacer(Modifier.height(8.dp)) + Text("Level $level", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(title, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + "Integrity ${(integrityScore * 100).toInt()}%", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +private fun XpProgressSection(totalXp: Long, xpInLevel: Long, xpForNextLevel: Long, level: Int) { + val fraction = if (xpForNextLevel > 0) (xpInLevel.toFloat() / xpForNextLevel.toFloat()) else 0f + val animatedFraction by animateFloatAsState( + targetValue = fraction.coerceIn(0f, 1f), + animationSpec = tween(800), + label = "xpBar", + ) + Column { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("XP - $totalXp all-time", style = MaterialTheme.typography.labelMedium) + Text("$xpInLevel / $xpForNextLevel", style = MaterialTheme.typography.labelMedium) + } + Spacer(Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { animatedFraction }, + modifier = Modifier + .fillMaxWidth() + .height(10.dp) + .clip(RoundedCornerShape(5.dp)), + ) + Text( + "Next level: ${level + 1}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.End), + ) + } +} + +@Composable +private fun XpLogSection(logs: List) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Ledger Event Log", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + if (logs.isEmpty()) { + Text( + "No ledger events yet. Finish a qualifying workout to start building your proof trail.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + logs.take(20).forEach { log -> + XpLogRow(log) + } + } + } + } +} + +@Composable +private fun XpLogRow(log: XpLogEntry) { + val c = useTheme() + val accent = if (log.kind == "level" || log.kind == "gate") c.gold else MaterialTheme.colorScheme.primary + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .size(10.dp) + .padding(top = 5.dp) + .clip(CircleShape) + .background(accent), + ) + Column(Modifier.weight(1f)) { + Text(log.title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + Text( + log.detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + if (log.xp > 0) "+${log.xp} XP" else log.kind.uppercase(), + style = MaterialTheme.typography.labelMedium, + color = accent, + fontWeight = FontWeight.Bold, + ) + } +} + +@Composable +private fun StreakSection(streakWeeks: Int, onRecoveryCircuitTap: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.16f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.34f)), + ) { + Box( + modifier = Modifier.size(44.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "FC", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Black, + ) + } + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + "$streakWeeks qualifying weeks", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text("Ledger weeks that met your training standard", style = MaterialTheme.typography.bodySmall) + } + TextButton(onClick = onRecoveryCircuitTap) { + Text("Recovery circuit") + } + } + } +} + +@Composable +private fun TrainingSignalsSection(stats: IronLedgerStats) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "Training Signals", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + SignalRow("STR", "Strength", stats.strength, MaterialTheme.colorScheme.primary) + SignalRow("PWR", "Power", stats.power, MaterialTheme.colorScheme.tertiary) + SignalRow("HYP", "Hypertrophy", stats.hypertrophy, MaterialTheme.colorScheme.secondary) + SignalRow("END", "Endurance", stats.endurance, MaterialTheme.colorScheme.primary) + SignalRow("AGI", "Agility", stats.agility, MaterialTheme.colorScheme.secondary) + SignalRow("DSC", "Discipline", stats.discipline, MaterialTheme.colorScheme.tertiary) + SignalRow("REC", "Recovery", stats.recovery, MaterialTheme.colorScheme.primary) + } + } +} + +@Composable +private fun SignalRow(code: String, label: String, value: Int, accent: Color) { + val normalized = (value.coerceIn(1, 100) / 100f).coerceAtLeast(0.03f) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + code, + modifier = Modifier.width(44.dp), + color = accent, + fontWeight = FontWeight.ExtraBold, + style = MaterialTheme.typography.labelLarge, + ) + Column(Modifier.weight(1f)) { + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LinearProgressIndicator( + progress = { normalized }, + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(999.dp)), + color = accent, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + Text( + value.toString(), + modifier = Modifier.width(32.dp), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.End, + ) + } +} + +@Composable +private fun NextGradeSection( + nextGradeLabel: String?, + gates: List, + totalXp: Long, +) { + val c = useTheme() + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Next Grade", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + nextGradeLabel ?: if (totalXp <= 0L) "Start with first workout" else "Apex maintained", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.primary, + ) + if (gates.isEmpty()) { + Text( + if (totalXp <= 0L) "Finish a qualifying workout to unlock your first grade gate." + else "No remaining gates.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + gates.forEach { gate -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(gate.label, style = MaterialTheme.typography.bodyMedium) + Text( + if (gate.met) "Met" else "${gate.current}/${gate.required}", + color = if (gate.met) c.success else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } +} + +@Composable +private fun BadgeShelf(unlockedBadges: List, onOpenBrowser: () -> Unit) { + Column { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Badges", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + TextButton(onClick = onOpenBrowser) { Text("View all") } + } + Spacer(Modifier.height(8.dp)) + if (unlockedBadges.isEmpty()) { + Text( + "No badges unlocked yet. Tap View all to inspect locked grades and requirements.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } else { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(unlockedBadges) { badge -> + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.primaryContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.25f)), + modifier = Modifier + .width(132.dp) + .clickable(onClick = onOpenBrowser), + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + badge.take(2).uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.primary, + ) + Text( + badge.replace('_', ' ') + .split(' ') + .joinToString(" ") { token -> token.replaceFirstChar(Char::titlecase) }, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + maxLines = 2, + ) + } + } + } + } + } + } +} + +@Composable +private fun GradeBrowserDialog(currentRank: String, onDismiss: () -> Unit) { + val c = useTheme() + val current = IronGrade.entries.firstOrNull { it.label == currentRank } ?: IronGrade.UNCALIBRATED + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Grade Atlas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + TextButton(onClick = onDismiss) { Text("Close") } + } + Text( + "Swipe sideways. Locked grades show the exact proof required.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(IronGrade.entries) { grade -> + val unlocked = current.ordinal >= grade.ordinal + val accent = ironGradeColor(grade.label) + Card( + modifier = Modifier.width(240.dp), + colors = CardDefaults.cardColors( + containerColor = if (unlocked) accent.copy(alpha = 0.12f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + ), + border = BorderStroke(1.dp, if (unlocked) accent.copy(alpha = 0.7f) else MaterialTheme.colorScheme.outline.copy(alpha = 0.45f)), + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + IronGradeBadge( + rank = grade.label, + accent = accent, + modifier = Modifier.size(84.dp), + ) + Text( + grade.label, + color = if (unlocked) accent else MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.ExtraBold, + ) + Text( + if (unlocked) "Unlocked" else "Locked", + color = if (unlocked) c.success else MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + GradeRequirement("Verified sessions", grade.minVerifiedSessions) + GradeRequirement("Qualifying weeks", grade.minQualifyingWeeks) + GradeRequirement("Training tenure", "${grade.minTenureDays} days") + if (grade.ordinal >= IronGrade.OBSIDIAN.ordinal) { + GradeRequirement("Integrity", "88%+") + } + if (grade.ordinal >= IronGrade.APEX.ordinal) { + GradeRequirement("Discipline window", "4+ years") + } + } + } + } + } + } + } + } +} + +@Composable +private fun GradeRequirement(label: String, value: Any) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value.toString(), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold) + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/home/HomeScreen.kt new file mode 100644 index 0000000..b11aa1d --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/home/HomeScreen.kt @@ -0,0 +1,1752 @@ +package com.ironlog.app.ui.screens.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForward +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.FitnessCenter +import androidx.compose.material.icons.outlined.LocalFireDepartment +import androidx.compose.material.icons.outlined.Loop +import androidx.compose.material.icons.outlined.MonitorWeight +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material3.Button +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.TextButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.layout +import androidx.compose.ui.res.painterResource +import com.ironlog.app.ui.rememberAnimatedCardBrush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ironlog.app.data.repository.BodyMeasurementRepository +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.UiPlanDay +import com.ironlog.app.ui.components.IronGradeBadge +import com.ironlog.app.ui.components.ironGradeColor +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogThemeTokens +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.viewmodel.AppDataViewModel +import com.ironlog.app.domain.intelligence.RecoveryReadinessEngine +import com.ironlog.app.domain.intelligence.TrainingIntelligenceEngine +import com.ironlog.app.domain.intelligence.WorkoutSuggestionEngine +import android.app.Application +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import com.ironlog.app.assets.ForgeFoxExpression +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.ui.viewmodel.GamificationViewModel +import com.ironlog.app.ui.viewmodel.GamificationViewModelFactory +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import com.ironlog.app.util.formatWeightFromKg +import com.ironlog.app.services.ShareService +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.time.temporal.WeekFields +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items as lazyItems +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import com.ironlog.app.data.health.BiometricSnapshot +import com.ironlog.app.data.health.HealthConnectRepository +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.domain.gamification.DailyProofStatus +import com.ironlog.app.domain.intelligence.ManualRecoveryInput +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.Dispatchers +import kotlinx.serialization.json.Json +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState +import androidx.health.connect.client.PermissionController +import com.ironlog.app.ui.screens.settings.HealthConnectPermissionSheet +import com.ironlog.app.ui.screens.intelligence.ApexEngineCard +import com.ironlog.app.ui.screens.intelligence.CloudAiCard +import com.ironlog.app.ui.screens.recovery.RecoveryHeatmapCard +import com.ironlog.app.ui.screens.workout.parseRepTarget + +@Composable +fun HomeScreen( + vm: AppDataViewModel = viewModel(), + onStartWorkout: (planId: String, dayId: String) -> Unit = { _, _ -> }, + onOpenBodyWeight: () -> Unit = {}, + onOpenRecovery: () -> Unit = {}, + onOpenTrainingIntelligence: () -> Unit = {}, + onOpenProgramPicker: () -> Unit = {}, + onOpenProgramInsights: () -> Unit = {}, + onOpenVolumeAnalytics: () -> Unit = {}, + onResumeWorkout: () -> Unit = {}, + onDiscardWorkout: () -> Unit = {}, + onOpenStatusWindow: () -> Unit = {}, +) { + val c = useTheme() + val context = LocalContext.current + val gamificationVm: GamificationViewModel = viewModel( + factory = GamificationViewModelFactory( + context.applicationContext as Application, + ObjectBox.store, + ) + ) + val gamState by gamificationVm.uiState.collectAsState() + val state by vm.state.collectAsState() + LaunchedEffect(state.history, state.settings.weeklyGoalDays) { + gamificationVm.refreshFromHistory(state.history, state.settings.weeklyGoalDays) + } + var latestBodyweight by remember { mutableStateOf(null) } + // FIXED: 10 — also fetch date and weekly delta for Body Weight card + var latestBodyweightDate by remember { mutableStateOf(null) } + var weeklyWeightDelta by remember { mutableStateOf(null) } + val bodyRepo = remember { BodyMeasurementRepository() } + LaunchedEffect(Unit) { + latestBodyweight = runCatching { bodyRepo.getLatestBodyweight() }.getOrNull() + latestBodyweightDate = runCatching { bodyRepo.getLatestBodyweightDate() }.getOrNull() + weeklyWeightDelta = runCatching { bodyRepo.getWeeklyBodyweightDelta() }.getOrNull() + } + + // Active workout live state — polls settings to check if a workout is in progress + val homeSettingsRepo = remember { SettingsRepository() } + var activeWorkoutDayName by remember { mutableStateOf(null) } + var activeWorkoutStartMs by remember { mutableLongStateOf(0L) } + var manualRecoveryInput by remember { mutableStateOf(null) } + var painFlags by remember { mutableStateOf(setOf()) } + val painRegions = remember { listOf("Push", "Pull", "Legs", "Core", "Arms", "Shoulders") } + LaunchedEffect(Unit) { + while (true) { + val (dayName, startMs) = withContext(Dispatchers.IO) { + val dn = homeSettingsRepo.getString("active_workout_day_name") + val sm = homeSettingsRepo.getString("active_workout_start_ms")?.toLongOrNull() ?: 0L + dn to sm + } + val nextDayName = if (dayName.isNullOrBlank()) null else dayName + if (activeWorkoutDayName != nextDayName) { + activeWorkoutDayName = nextDayName + } + if (activeWorkoutStartMs != startMs) { + activeWorkoutStartMs = startMs + } + delay(1500) + } + } + LaunchedEffect(Unit) { + while (true) { + val (recovery, pain) = withContext(Dispatchers.IO) { + val r = homeSettingsRepo.getString("manual_recovery_input") + ?.let { raw -> runCatching { Json.decodeFromString(ManualRecoveryInput.serializer(), raw) }.getOrNull() } + val p = painRegions + .filter { region -> homeSettingsRepo.getString("pain_flag_${region}") == "true" } + .toSet() + r to p + } + manualRecoveryInput = recovery + painFlags = pain + delay(1200) + } + } + + // Health Connect — biometric enrichment for recovery readiness + val healthRepo = remember { HealthConnectRepository(context) } + val coroutineScope = rememberCoroutineScope() + var showHealthConnectSheet by remember { mutableStateOf(false) } + var biometricSnapshot: BiometricSnapshot by remember { mutableStateOf(BiometricSnapshot()) } + LaunchedEffect(Unit) { + if (healthRepo.isAvailable()) { + if (!healthRepo.hasAllPermissions()) { + val prefs = context.getSharedPreferences("hc_prefs", 0) + if (!prefs.getBoolean("hc_sheet_shown", false)) { + showHealthConnectSheet = true + prefs.edit().putBoolean("hc_sheet_shown", true).apply() + } + } else { + biometricSnapshot = withContext(Dispatchers.IO) { healthRepo.readBiometricSnapshot() } + } + } + } + + val streak = remember(state.history) { getStreak(state.history) } + val recentSessions = remember(state.history) { + val cutoff = LocalDate.now().minusDays(30).toString() + state.history.count { it.date.substringBefore('T') >= cutoff } + } + val avgDurationMin = remember(state.history) { + if (state.history.isEmpty()) 0 + else (state.history.take(10).sumOf { it.duration }.toDouble() / state.history.take(10).size / 60.0).roundToInt().coerceAtLeast(1) + } + val weeklyGoalDays = state.settings.weeklyGoalDays.coerceIn(1, 7) + val thisWeekSessions = remember(state.history) { countSessionsThisWeek(state.history) } + val thisWeekSets = remember(state.history) { + val now = LocalDate.now() + val fields = WeekFields.ISO + val y = now.get(fields.weekBasedYear()) + val w = now.get(fields.weekOfWeekBasedYear()) + state.history + .filter { + runCatching { LocalDate.parse(it.date.substringBefore('T')) }.getOrNull()?.let { d -> + d.get(fields.weekBasedYear()) == y && d.get(fields.weekOfWeekBasedYear()) == w + } == true + } + .sumOf { it.sets } + } + val thisWeekVolumeKg = remember(state.history) { + val now = LocalDate.now() + val fields = WeekFields.ISO + val y = now.get(fields.weekBasedYear()) + val w = now.get(fields.weekOfWeekBasedYear()) + state.history + .filter { + runCatching { LocalDate.parse(it.date.substringBefore('T')) }.getOrNull()?.let { d -> + d.get(fields.weekBasedYear()) == y && d.get(fields.weekOfWeekBasedYear()) == w + } == true + } + .sumOf { it.volume } + .roundToInt() + } + val goalStreak = remember(state.history, weeklyGoalDays) { getGoalStreak(state.history, weeklyGoalDays) } + // Prefer the plan explicitly marked active; fall back to first (most-recently-updated) plan + val activePlan = state.plans.firstOrNull { it.isActive } ?: state.plans.firstOrNull() + val planDays = activePlan?.days ?: emptyList() + val goalModeLabel = state.settings.goalMode + .replace("_", " ") + .split(" ") + .joinToString(" ") { it.replaceFirstChar { ch -> ch.titlecase() } } + + val filteredHistory30d = remember(state.history) { state.history.filter { homeScreenAgeDays(it.date) <= 30 } } + val readiness = remember(filteredHistory30d, painFlags) { + RecoveryReadinessEngine.readinessByRegion(filteredHistory30d, painFlags) + } + + // Fatigue-based workout suggestion — reorder plan days so the freshest is first + val suggestionEngine = remember { WorkoutSuggestionEngine() } + val orderedPlanDays = remember(planDays, readiness) { + if (planDays.size <= 1) planDays + else { + val dayExerciseNames = planDays.map { day -> day.exercises.map { it.name } } + val bestIdx = suggestionEngine.suggestDayIndex(readiness, dayExerciseNames) + val recommended = planDays[bestIdx] + listOf(recommended) + planDays.filterIndexed { i, _ -> i != bestIdx } + } + } + val recommendedDayId: String? = orderedPlanDays.firstOrNull()?.id + val dailyProofAction = remember( + gamState.dailyProofPrimaryRoute, + activePlan?.id, + recommendedDayId, + planDays, + ) { + { + when (gamState.dailyProofPrimaryRoute) { + "ActiveWorkout" -> onResumeWorkout() + "RecoveryMap" -> onOpenRecovery() + "statusWindow" -> onOpenStatusWindow() + "ProgramPicker" -> onOpenProgramPicker() + "Home" -> { + val targetDayId = recommendedDayId ?: planDays.firstOrNull()?.id.orEmpty() + if (activePlan != null && targetDayId.isNotBlank()) { + onStartWorkout(activePlan.id, targetDayId) + } else { + onOpenProgramPicker() + } + } + else -> onOpenStatusWindow() + } + } + } + val recoveryBlurb: String? = remember(readiness, orderedPlanDays) { + orderedPlanDays.firstOrNull()?.let { day -> + if (planDays.size > 1) suggestionEngine.recommendationBlurb(readiness, day.name) else null + } + } + + val intelligenceSnapshot = remember(state.history) { + TrainingIntelligenceEngine.build(state.history) + } + + // Step 7: thisWeekSets/thisWeekVolumeKg/totalSessions/unlockedMilestones removed (cards moved to StatsScreen) + + // Health Connect permission sheet — shown once on first open if HC is available but not yet authorized + if (showHealthConnectSheet && healthRepo.isAvailable()) { + val permLauncher = rememberLauncherForActivityResult( + PermissionController.createRequestPermissionResultContract() + ) { grantedPerms -> + showHealthConnectSheet = false + if (grantedPerms.containsAll(healthRepo.requiredPermissions)) { + coroutineScope.launch { + biometricSnapshot = withContext(Dispatchers.IO) { healthRepo.readBiometricSnapshot() } + } + } + } + HealthConnectPermissionSheet( + onRequestPermissions = { permLauncher.launch(healthRepo.requiredPermissions) }, + onDismiss = { showHealthConnectSheet = false }, + ) + } + + LazyColumn( + Modifier.fillMaxSize().background(c.bg).statusBarsPadding(), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, top = 24.dp, bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + // FIXED: 8 — date eyebrow + context-aware subtitle + Text( + LocalDate.now().format(DateTimeFormatter.ofPattern("EEEE, MMM d")).uppercase(), + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + Text( + streakContextSubtitle(streak, state.history.firstOrNull()?.date), + color = c.muted, + fontSize = IronLogType.body.fontSize.sp, + ) + Text( + state.settings.userName.ifBlank { "Athlete" }, + color = c.accent, + fontWeight = FontWeight(IronLogType.display.fontWeight), + fontSize = IronLogType.display.fontSize.sp, + lineHeight = IronLogType.display.lineHeight.sp, + ) + } + // Task 10 — compact XP / rank bar + item { + DailyProofCard( + gamState = gamState, + onPrimaryAction = dailyProofAction, + onOpenLedger = onOpenStatusWindow, + ) + } + item { + val xpAnim by animateFloatAsState( + targetValue = if (gamState.xpForNextLevel > 0L) + (gamState.xpInLevel.toFloat() / gamState.xpForNextLevel.toFloat()).coerceIn(0f, 1f) + else 0f, + label = "xpBarAnim", + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable { onOpenStatusWindow() } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Rank + Level badge + Surface( + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + IronGradeBadge( + rank = gamState.rank, + accent = ironGradeColor(gamState.rank), + modifier = Modifier.size(22.dp), + ) + Text( + text = "${gamState.rank} · Lv.${gamState.level}", + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + // XP progress bar + Box(modifier = Modifier.weight(1f)) { + LinearProgressIndicator( + progress = { xpAnim }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + + // XP label + Text( + text = "${gamState.xpInLevel}/${gamState.xpForNextLevel}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.sp, + ) + + // Streak badge + if (gamState.streakWeeks > 0) { + Surface( + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Text( + text = "${gamState.streakWeeks}w streak", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onTertiaryContainer, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + item { + if (activePlan == null) { + // Only show the empty state once the plan repository has confirmed it has no plans. + // Until then, render a placeholder skeleton so we never flash "No program selected" + // on cold start while plans are still loading from ObjectBox. + if (state.plansLoaded) { + NoPlanCard(onOpenPlans = onOpenProgramPicker) + } else { + Box( + Modifier + .fillMaxWidth() + .height(180.dp) + .clip(RoundedCornerShape(IronLogRadius.xl.dp)) + .background(c.faint) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.xl.dp)), + ) + } + } else { + StartWorkoutCard( + goalModeLabel = goalModeLabel, + activePlanName = activePlan.name, + avgDurationMin = avgDurationMin, + planDays = orderedPlanDays, + recommendedDayId = recommendedDayId, + recoveryBlurb = recoveryBlurb, + history = state.history, + pbKeys = state.pb.keys, + onClick = { onStartWorkout(activePlan.id, orderedPlanDays.firstOrNull()?.id.orEmpty()) }, + onStartDay = { dayId -> onStartWorkout(activePlan.id, dayId) }, + activeWorkoutDayName = activeWorkoutDayName, + activeWorkoutStartMs = activeWorkoutStartMs, + onResumeWorkout = onResumeWorkout, + onDiscardWorkout = onDiscardWorkout, + ) + } + } + item { HomeStatsRow(sessions = recentSessions, streak = streak, avgDurationMin = avgDurationMin) } + item { + WeeklyGoalCard( + sessions = thisWeekSessions, + goalDays = weeklyGoalDays, + goalStreak = goalStreak, + ) + } + if (planDays.isNotEmpty()) { + item { + ThisWeekDayChips( + planDays = planDays, + history = state.history, + ) + } + } + item { + WeeklySummaryCard( + sessions = thisWeekSessions, + sets = thisWeekSets, + volumeKg = thisWeekVolumeKg, + weightUnit = state.settings.weightUnit, + streak = streak, + onShare = { + ShareService.shareMinimalCardImage( + context = context, + title = "Ironlog Weekly Summary", + headline = "This Week", + metrics = listOf( + ShareService.ShareMetric("Workouts", thisWeekSessions.toString()), + ShareService.ShareMetric("Sets", thisWeekSets.toString()), + ShareService.ShareMetric("Volume", formatWeightFromKg(thisWeekVolumeKg.toDouble(), state.settings.weightUnit)), + ShareService.ShareMetric("Streak", "$streak d"), + ), + ) + }, + ) + } + // Training Intelligence / APEX ENGINE card — switches based on intelligenceMode setting + item { + val sessionsPerWeek = (recentSessions / 4.3f).roundToInt().coerceAtLeast(0) + val adherencePct = if (weeklyGoalDays > 0) (thisWeekSessions * 100 / weeklyGoalDays).coerceAtMost(100) else 0 + when (state.settings.intelligenceMode) { + "gemini_nano" -> ApexEngineCard( + activePlanDay = activePlan?.days?.firstOrNull(), + history = state.history, + lastWorkoutId = state.history.firstOrNull()?.id, + goalMode = state.settings.goalMode, + sessionsPerWeek = sessionsPerWeek, + adherencePct = adherencePct, + weeklyGoalDays = state.settings.weeklyGoalDays, + prTrend = intelligenceSnapshot.prTrend, + readiness = readiness, + onSwitchToBuiltin = { + vm.updateSettingsAsync(state.settings.copy(intelligenceMode = "builtin")) + }, + onOpenTrainingIntelligence = onOpenTrainingIntelligence, + ) + "cloud_ai" -> CloudAiCard( + activePlanDay = activePlan?.days?.firstOrNull(), + history = state.history, + lastWorkoutId = state.history.firstOrNull()?.id, + goalMode = state.settings.goalMode, + sessionsPerWeek = sessionsPerWeek, + adherencePct = adherencePct, + weeklyGoalDays = state.settings.weeklyGoalDays, + prTrend = intelligenceSnapshot.prTrend, + readiness = readiness, + displayName = state.settings.cloudAiDisplayName.ifBlank { "Cloud AI" }, + modelName = state.settings.cloudAiModelName, + baseUrl = state.settings.cloudAiBaseUrl, + apiFormat = state.settings.cloudAiApiFormat, + providerPreset = state.settings.cloudAiProviderPreset, + onSwitchToBuiltin = { + vm.updateSettingsAsync(state.settings.copy(intelligenceMode = "builtin")) + }, + onOpenTrainingIntelligence = onOpenTrainingIntelligence, + ) + else -> TrainingIntelligenceCard( + activePlanName = activePlan?.name, + sessionsPerWeek = sessionsPerWeek, + adherencePct = adherencePct, + recommendedDay = activePlan?.days?.firstOrNull(), + history = state.history, + weightUnit = state.settings.weightUnit, + onOpenInsights = onOpenProgramInsights, + onOpenTrainingIntelligence = onOpenTrainingIntelligence, + ) + } + } + // Recovery card — full width so the body map isn't squished + item { + RecoveryHeatmapCard( + groupReadiness = readiness, + manualRecoveryInput = manualRecoveryInput, + onTapExpand = onOpenRecovery, + onOpenVolumeAnalytics = onOpenVolumeAnalytics, + ) + } + // Body Weight card — compact full-width row + item { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(c.card) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .clickable(onClick = onOpenBodyWeight) + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon(Icons.Outlined.MonitorWeight, null, tint = c.accent, modifier = Modifier.size(20.dp)) + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + "BODY WEIGHT", + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + val weightText = latestBodyweight?.takeIf { it > 0.0 } + ?.let { formatWeightFromKg(it, state.settings.weightUnit) } + ?: "-- ${state.settings.weightUnit}" + Text( + weightText, + color = c.text, + fontSize = IronLogType.title.fontSize.sp, + fontWeight = FontWeight(IronLogType.display.fontWeight), + letterSpacing = (-0.5).sp, + ) + latestBodyweightDate?.let { date -> + Text("Logged $date", color = c.muted, fontSize = IronLogType.micro.fontSize.sp) + } + } + } + weeklyWeightDelta?.let { delta -> + if (kotlin.math.abs(delta) >= 0.05) { + val sign = if (delta >= 0) "+" else "" + Text( + "$sign${formatWeightFromKg(kotlin.math.abs(delta), state.settings.weightUnit)} wk", + color = if (delta <= 0) c.success else c.warning, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + // Athlete profile summary card + item { + AthleteProfileCard( + totalSessions = state.history.size, + streak = streak, + onOpen = onOpenTrainingIntelligence, + ) + } + // Milestones card — only shown when at least one PB has been unlocked + if (state.pb.keys.isNotEmpty()) { + item { MilestonesCard(unlocked = state.pb.keys) } + } + } +} + +@Composable +private fun DailyProofCard( + gamState: com.ironlog.app.ui.viewmodel.GamificationUiState, + onPrimaryAction: () -> Unit, + onOpenLedger: () -> Unit, +) { + val c = useTheme() + val xpAnim by animateFloatAsState( + targetValue = if (gamState.xpForNextLevel > 0L) { + (gamState.xpInLevel.toFloat() / gamState.xpForNextLevel.toFloat()).coerceIn(0f, 1f) + } else { + 0f + }, + label = "dailyProofXp", + ) + val accent = when (gamState.dailyProofStatus) { + DailyProofStatus.PROOF_LOGGED -> c.success + DailyProofStatus.RECOVER_SMART -> c.info + DailyProofStatus.AT_RISK -> c.warning + DailyProofStatus.SETUP -> c.accent + else -> c.accent + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.xl.dp)) + .background(c.card) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.xl.dp)) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + "DAILY PROOF", + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + Text( + gamState.dailyProofHeadline, + color = c.text, + fontSize = IronLogType.title.fontSize.sp, + fontWeight = FontWeight.ExtraBold, + lineHeight = IronLogType.title.lineHeight.sp, + ) + Text( + gamState.dailyProofDetail, + color = c.subtext, + fontSize = IronLogType.body.fontSize.sp, + lineHeight = IronLogType.body.lineHeight.sp, + ) + } + + Image( + painter = painterResource(ForgeFoxExpression.fromId(gamState.foxExpressionId).drawableRes), + contentDescription = gamState.dailyProofHeadline, + modifier = Modifier + .padding(start = 12.dp) + .size(112.dp), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = accent.copy(alpha = 0.12f), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IronGradeBadge( + rank = gamState.rank, + accent = ironGradeColor(gamState.rank), + modifier = Modifier.size(24.dp), + ) + Text( + text = "${gamState.rank} · Lv.${gamState.level}", + color = c.text, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ) + } + } + + if (gamState.dailyStreakDays > 0) { + Surface( + shape = RoundedCornerShape(12.dp), + color = c.surface, + ) { + Text( + text = "${gamState.dailyStreakDays}d streak", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + color = c.text, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ) + } + } + + if (gamState.latestBadgeTitle != null) { + Surface( + shape = RoundedCornerShape(12.dp), + color = c.surface, + ) { + Text( + text = gamState.latestBadgeTitle, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + color = c.accent, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + LinearProgressIndicator( + progress = { xpAnim }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = accent, + trackColor = c.faint, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${gamState.xpInLevel}/${gamState.xpForNextLevel} XP", + color = c.subtext, + fontSize = 11.sp, + ) + Text( + text = "${gamState.streakWeeks} qualifying weeks", + color = c.subtext, + fontSize = 11.sp, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Button( + onClick = onPrimaryAction, + modifier = Modifier.weight(1f), + ) { + Text(gamState.dailyProofPrimaryActionLabel.uppercase()) + } + TextButton( + onClick = onOpenLedger, + modifier = Modifier.weight(1f), + ) { + Text("OPEN IRON LEDGER") + } + } + } +} + +@Composable +private fun ThisWeekDayChips( + planDays: List, + history: List, +) { + val c = useTheme() + val fields = remember { WeekFields.ISO } + val now = LocalDate.now() + val nowYear = now.get(fields.weekBasedYear()) + val nowWeek = now.get(fields.weekOfWeekBasedYear()) + val thisWeekHistory = remember(history) { + history.filter { + runCatching { LocalDate.parse(it.date.substringBefore('T')) }.getOrNull()?.let { d -> + d.get(fields.weekBasedYear()) == nowYear && d.get(fields.weekOfWeekBasedYear()) == nowWeek + } == true + } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "THIS WEEK", + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + lazyItems(planDays) { day -> + val hit = thisWeekHistory.any { + (it.planDayUid != null && it.planDayUid == day.id) || + ((it.dayName ?: it.name).trim().equals(day.name.trim(), ignoreCase = true)) + } + Box( + modifier = Modifier + .clip(CircleShape) + .background(if (hit) c.accent.copy(alpha = 0.18f) else c.surface) + .border(1.dp, if (hit) c.accent else c.cardBorder, CircleShape) + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + day.name, + color = if (hit) c.accent else c.subtext, + fontWeight = if (hit) FontWeight.Bold else FontWeight.Medium, + fontSize = IronLogType.meta.fontSize.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + + +private fun timeGreeting(): String { + val hour = java.time.LocalTime.now().hour + return when { + hour < 5 -> "Good night" + hour < 12 -> "Good morning" + hour < 18 -> "Good afternoon" + hour < 22 -> "Good evening" + else -> "Good night" + } +} + +// FIXED: 8 — context-aware subtitle based on streak/recency +private fun streakContextSubtitle(streak: Int, lastWorkoutDate: String?): String { + if (streak >= 30) return "🔥 $streak day streak — legendary!" + if (streak >= 14) return "🔥 $streak day streak — unstoppable!" + if (streak >= 7) return "🔥 $streak day streak — keep it going!" + if (streak >= 3) return "💪 $streak days strong — don't stop now!" + val iso = lastWorkoutDate?.substringBefore('T') ?: return timeGreeting() + val days = runCatching { + ChronoUnit.DAYS.between(LocalDate.parse(iso), LocalDate.now()).toInt() + }.getOrDefault(-1) + return when (days) { + 0 -> "You trained today — amazing!" + 1 -> "Pick up where you left off" + in 2..3 -> "$days days since last workout" + in 4..7 -> "A week away — come back strong" + else -> timeGreeting() + } +} + +@Composable +private fun WeeklyGoalCard(sessions: Int, goalDays: Int, goalStreak: Int) { + val c = useTheme() + val progress = (sessions.toFloat() / goalDays.toFloat()).coerceIn(0f, 1f) + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(c.card) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Weekly Goal", color = c.text, fontWeight = FontWeight(IronLogType.section.fontWeight), fontSize = IronLogType.section.fontSize.sp) + Text( + "$sessions / $goalDays", + color = if (progress >= 1f) c.success else c.accent, + fontWeight = FontWeight.ExtraBold, + fontSize = IronLogType.body.fontSize.sp, + ) + } + // Progress track + Box( + Modifier + .fillMaxWidth() + .height(10.dp) + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.faint), + ) { + Box( + Modifier + .fillMaxWidth(progress) + .fillMaxHeight() + .background( + brush = androidx.compose.ui.graphics.Brush.horizontalGradient( + colors = listOf(c.accent, c.accent.copy(alpha = 0.7f)), + ), + ), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + "Goal streak: ${goalStreak} week${if (goalStreak == 1) "" else "s"}", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } +} + +@Composable +private fun NoPlanCard(onOpenPlans: () -> Unit) { + val c = useTheme() + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.xl.dp)) + .background(c.faint) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.xl.dp)) + .padding(20.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Outlined.FitnessCenter, contentDescription = null, tint = c.muted, modifier = Modifier.size(32.dp)) + Text( + "No program selected", + color = c.subtext, + fontWeight = FontWeight(IronLogType.section.fontWeight), + fontSize = IronLogType.section.fontSize.sp, + ) + Text( + "Pick a plan and start building.", + color = c.muted, + fontSize = IronLogType.body.fontSize.sp, + ) + Button(onClick = onOpenPlans) { + Text("BROWSE PROGRAMS") + } + } + } +} + +@Composable +private fun ActiveWorkoutTimerText(startMs: Long, c: IronLogThemeTokens) { + var elapsedSeconds by remember(startMs) { + mutableLongStateOf( + if (startMs > 0L) (System.currentTimeMillis() - startMs) / 1000L else 0L + ) + } + LaunchedEffect(startMs) { + if (startMs > 0L) { + while (true) { + elapsedSeconds = (System.currentTimeMillis() - startMs) / 1000L + delay(1000) + } + } + } + val timerText = remember(elapsedSeconds) { + val s = elapsedSeconds + val m = s / 60; val h = m / 60 + if (h > 0) "$h:${(m % 60).toString().padStart(2, '0')}:${(s % 60).toString().padStart(2, '0')}" + else "${m.toString().padStart(2, '0')}:${(s % 60).toString().padStart(2, '0')}" + } + Text(timerText, color = c.accent, fontWeight = FontWeight.Black, fontSize = IronLogType.body.fontSize.sp) +} + +@Composable +private fun StartWorkoutCard( + goalModeLabel: String, + activePlanName: String, + avgDurationMin: Int, + planDays: List = emptyList(), + recommendedDayId: String? = null, + recoveryBlurb: String? = null, + history: List = emptyList(), + pbKeys: Set = emptySet(), + onClick: () -> Unit = {}, + onStartDay: (dayId: String) -> Unit = {}, + activeWorkoutDayName: String? = null, + activeWorkoutStartMs: Long = 0L, + onResumeWorkout: () -> Unit = {}, + onDiscardWorkout: () -> Unit = {}, +) { + val c = useTheme() + // A workout is active as soon as its day name is persisted — elapsed may still be 0 if no + // sets have been logged yet (timer only starts on first set). + val isWorkoutActive = !activeWorkoutDayName.isNullOrBlank() + val animatedBrush = rememberAnimatedCardBrush(c.accent) + + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.xl.dp)) + .background(c.card) + .background(animatedBrush) + .border(2.dp, c.accent.copy(alpha = 0.50f), RoundedCornerShape(IronLogRadius.xl.dp)), + ) { + Column( + Modifier.padding(horizontal = 20.dp, vertical = 22.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Eyebrow + Text( + if (isWorkoutActive) "IN PROGRESS" else "TODAY'S WORKOUT", + color = c.accent, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + // Top row: play icon (left) + arrow button (right) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .size(50.dp) + .clip(RoundedCornerShape(14.dp)) + .background(c.textOnAccent), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .fillMaxSize() + .clickable { if (isWorkoutActive) onResumeWorkout() else onClick() }, + ) + Icon( + if (isWorkoutActive) Icons.Outlined.Loop else Icons.Outlined.PlayArrow, + contentDescription = null, + tint = c.bg, + modifier = Modifier.size(26.dp), + ) + } + Box( + Modifier + .size(44.dp) + .clip(CircleShape) + .background(c.accent.copy(alpha = 0.12f)) + .border(1.dp, c.accent.copy(alpha = 0.20f), CircleShape) + .clickable { if (isWorkoutActive) onResumeWorkout() else onClick() }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.AutoMirrored.Outlined.ArrowForward, + contentDescription = null, + tint = c.accent, + modifier = Modifier.size(15.dp), + ) + } + } + + // Plan / workout name + badges row + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + if (isWorkoutActive) (activeWorkoutDayName ?: "Workout") else activePlanName.ifBlank { "Start Workout" }, + color = c.text, + fontWeight = FontWeight(IronLogType.display.fontWeight), + fontSize = IronLogType.display.fontSize.sp, + lineHeight = IronLogType.display.lineHeight.sp, + letterSpacing = (-0.5).sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isWorkoutActive) { + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.success.copy(alpha = 0.18f)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + // FIXED: 1 + Text("● IN PROGRESS", color = c.success, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = IronLogType.micro.letterSpacing.sp) + } + ActiveWorkoutTimerText(activeWorkoutStartMs, c) + } else { + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.accent.copy(alpha = 0.14f)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text(goalModeLabel, color = c.accent, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.5.sp) + } + if (planDays.isNotEmpty()) { + Text("${planDays.size} days" + if (avgDurationMin > 0) " · avg ${avgDurationMin}m" else "", color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + } + + if (isWorkoutActive) { + // Resume CTA + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.md.dp)) + .background(c.accent) + .clickable(onClick = onResumeWorkout) + .padding(vertical = 13.dp), + contentAlignment = Alignment.Center, + ) { + Text("RESUME WORKOUT", color = c.textOnAccent, fontWeight = FontWeight.ExtraBold, fontSize = IronLogType.button.fontSize.sp, letterSpacing = IronLogType.button.letterSpacing.sp) + } + // Discard stale workout — clears stuck state after a crash + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.sm.dp)) + .border(1.dp, c.danger.copy(alpha = 0.30f), RoundedCornerShape(IronLogRadius.sm.dp)) + .clickable(onClick = onDiscardWorkout) + .padding(vertical = 9.dp), + contentAlignment = Alignment.Center, + ) { + Text("DISCARD WORKOUT", color = c.danger.copy(alpha = 0.75f), fontWeight = FontWeight.Bold, fontSize = IronLogType.micro.fontSize.sp, letterSpacing = IronLogType.button.letterSpacing.sp) + } + } else if (planDays.isNotEmpty()) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(start = 20.dp, top = 4.dp, end = 20.dp, bottom = 0.dp), + modifier = Modifier + .fillMaxWidth() + .layout { measurable, constraints -> + val sidePx = 20.dp.roundToPx() + val placeable = measurable.measure( + constraints.copy(maxWidth = constraints.maxWidth + sidePx * 2) + ) + layout(constraints.maxWidth, placeable.height) { + placeable.place(-sidePx, 0) + } + }, + ) { + lazyItems(planDays) { day -> + val isRecommended = day.id == recommendedDayId + // Pulsing glow on the recommended card + val infiniteTransition = rememberInfiniteTransition(label = "glow_${day.id}") + val glowAlpha by infiniteTransition.animateFloat( + initialValue = 0.45f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900), + repeatMode = RepeatMode.Reverse, + ), + label = "glowAlpha_${day.id}", + ) + val borderColor by animateColorAsState( + targetValue = if (isRecommended) c.accent.copy(alpha = glowAlpha) + else c.text.copy(alpha = 0.26f), + animationSpec = tween(400), + label = "borderColor_${day.id}", + ) + val borderWidth = if (isRecommended) 2.dp else 1.dp + val cardBg = if (isRecommended) c.accent.copy(alpha = 0.10f) + else c.text.copy(alpha = 0.08f) + + Box( + Modifier + .width(128.dp) + .clip(RoundedCornerShape(22.dp)) + .background(cardBg) + .border(borderWidth, borderColor, RoundedCornerShape(22.dp)) + .clickable { onStartDay(day.id) }, + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + day.name.uppercase(), + color = if (isRecommended) c.accent else c.text, + fontWeight = FontWeight.ExtraBold, + fontSize = 13.sp, + letterSpacing = 0.5.sp, + maxLines = 1, + ) + Text( + "${day.exercises.size} ex", + color = c.muted, + fontSize = 10.sp, + letterSpacing = 0.1.sp, + ) + if (isRecommended) { + Text( + "⚡ Best Today", + color = c.accent, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.3.sp, + ) + } + } + } + } + } + // Recovery blurb — shown when engine has a recommendation + if (recoveryBlurb != null) { + Text( + text = recoveryBlurb, + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier.padding(horizontal = 2.dp), + ) + } + + // Quick-start without plan + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.sm.dp)) + .border(1.dp, c.accent.copy(alpha = 0.13f), RoundedCornerShape(IronLogRadius.sm.dp)) + .clickable(onClick = onClick) + .padding(vertical = 9.dp), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Icon(Icons.Outlined.Add, null, tint = c.muted, modifier = Modifier.size(12.dp)) + // FIXED: 1 + Text("START WITHOUT PLAN", color = c.muted, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = IronLogType.micro.letterSpacing.sp) + } + } + } else { + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.md.dp)) + .background(c.accent) + .clickable(onClick = onClick) + .padding(vertical = 13.dp), + contentAlignment = Alignment.Center, + ) { + // FIXED: 1 + Text("START WORKOUT", color = c.textOnAccent, fontWeight = FontWeight.ExtraBold, fontSize = IronLogType.button.fontSize.sp, letterSpacing = IronLogType.button.letterSpacing.sp) + } + } + } + } +} + +@Composable +private fun HomeStatsRow(sessions: Int, streak: Int, avgDurationMin: Int) { + val c = useTheme() + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(c.card) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(vertical = 18.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + HomeStatMetric(sessions.toString(), "Sessions") + Box(Modifier.width(1.dp).height(36.dp).background(c.faint)) + HomeStatMetric(streak.toString(), "Streak") + Box(Modifier.width(1.dp).height(36.dp).background(c.faint)) + HomeStatMetric(if (avgDurationMin > 0) "${avgDurationMin}m" else "--", "Avg Min") + } +} + +@Composable +private fun HomeStatMetric(value: String, label: String) { + val c = useTheme() + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (label == "Streak") { + Icon( + Icons.Outlined.LocalFireDepartment, + contentDescription = null, + tint = c.accent, + modifier = Modifier.size(14.dp), + ) + Spacer(Modifier.height(2.dp)) + } + Text( + value, + color = c.text, + fontWeight = FontWeight(IronLogType.display.fontWeight), + fontSize = IronLogType.title.fontSize.sp, + ) + Text( + label, + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + } +} + +@Composable +private fun FullWidthIntelCard( + sup: String, + accentColor: androidx.compose.ui.graphics.Color? = null, + onClick: () -> Unit = {}, + content: @Composable ColumnScope.() -> Unit +) { + val c = useTheme() + val barColor = accentColor ?: c.accent + Row( + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clip(RoundedCornerShape(IronLogRadius.md.dp)) + .background(c.card) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.md.dp)) + .clickable(onClick = onClick), + ) { + Box(Modifier.width(4.dp).fillMaxHeight().background(barColor)) + Column( + Modifier.weight(1f).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(sup, color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), letterSpacing = IronLogType.eyebrow.letterSpacing.sp) + content() + } + } +} + +@Composable +private fun TrainingIntelligenceCard( + activePlanName: String?, + sessionsPerWeek: Int, + adherencePct: Int, + recommendedDay: UiPlanDay?, + history: List, + weightUnit: String, + onOpenInsights: () -> Unit, + onOpenTrainingIntelligence: () -> Unit, +) { + val c = useTheme() + val cardBg = c.accent.copy(alpha = 0.10f) + val accentBorder = c.accent.copy(alpha = 0.35f) + val nextLabel = activePlanName?.let { name -> "Your next session from \"$name\" is ready." } + ?: "Build adaptive targets around your schedule." + + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(cardBg) + .border(1.dp, accentBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Eyebrow + Text( + "TRAINING INTELLIGENCE", + color = c.accent, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + // Dynamic headline + Text( + nextLabel, + color = c.text, + fontSize = IronLogType.section.fontSize.sp, + fontWeight = FontWeight.Bold, + lineHeight = IronLogType.section.lineHeight.sp, + ) + // Subtext + Text( + "Log sessions to unlock adaptive targets that respond to your progress.", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + // Stats row + Text( + "$sessionsPerWeek sessions/wk · $adherencePct% adherence", + color = c.subtext, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = FontWeight.Medium, + ) + val suggestions = remember(recommendedDay, history) { + buildAdaptiveTargets(recommendedDay, history) + } + if (suggestions.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + suggestions.forEach { s -> + val base = + if (s.suggestedWeightKg != null) { + "${s.name} → ${formatWeightFromKg(s.suggestedWeightKg, weightUnit)} × ${s.suggestedReps}" + } else { + "${s.name} → Build to a working weight" + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + base, + color = c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + ) + if (s.plateau) { + Text( + "⟳ plateau", + color = c.warning, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } + } + // CTA row + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // Outline CTA button + Box( + Modifier + .border(1.dp, c.accent, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable(onClick = onOpenInsights) + .padding(horizontal = 14.dp, vertical = 7.dp), + ) { + Text( + "OPEN PROGRAM INSIGHTS →", + color = c.accent, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight(IronLogType.button.fontWeight), + ) + } + } + // Recommended plan link + Text( + "✦ Open recommended plan →", + color = c.accent, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.clickable(onClick = onOpenTrainingIntelligence), + ) + } +} + +private data class AdaptiveTarget( + val name: String, + val suggestedWeightKg: Double?, + val suggestedReps: Int, + val plateau: Boolean, +) + +private fun buildAdaptiveTargets(day: UiPlanDay?, history: List): List { + if (day == null) return emptyList() + return day.exercises.take(3).map { ex -> + val allSets = history + .flatMap { it.exercises } + .filter { hx -> + hx.exerciseId == ex.exerciseId || hx.name.equals(ex.name, ignoreCase = true) + } + .flatMap { it.sets.filter { st -> st.type != "warmup" && st.weight > 0 && st.reps > 0 } } + if (allSets.isEmpty()) { + AdaptiveTarget(ex.name, null, parseRepTarget(ex.reps, 8), plateau = false) + } else { + val latest = allSets.last() + val e1rm = latest.weight * (1.0 + latest.reps / 30.0) + val suggested = ((e1rm * 0.85) / 2.5).roundToInt() * 2.5 + val recentE1rm = allSets.takeLast(3).map { it.weight * (1.0 + it.reps / 30.0) } + val plateau = recentE1rm.size >= 3 && recentE1rm.zipWithNext().all { (a, b) -> b <= a + 0.25 } + AdaptiveTarget( + name = ex.name, + suggestedWeightKg = suggested.coerceAtLeast(2.5), + suggestedReps = parseRepTarget(ex.reps, 8), + plateau = plateau, + ) + } + } +} + +@Composable +private fun AccentBorderCard(modifier: Modifier, onClick: () -> Unit = {}, content: @Composable ColumnScope.() -> Unit) { + val c = useTheme() + Row( + modifier + .height(IntrinsicSize.Min) + .clip(RoundedCornerShape(IronLogRadius.md.dp)) + .background(c.surface) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.md.dp)) + .clickable(onClick = onClick), + ) { + Box(Modifier.width(3.dp).fillMaxHeight().background(c.accent)) + Column( + Modifier.weight(1f).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + content() + } + } +} + +private fun homeScreenAgeDays(iso: String): Long = runCatching { + // Instant.parse requires a full ISO-8601 instant (with time + Z). + // Bare date strings like "2025-01-01" will throw DateTimeParseException; + // fall back to LocalDate.parse for those, and return MAX_VALUE on any failure + // so the entry is treated as too old to show (filtered out of the 30-day window). + val date = try { + Instant.parse(iso).atZone(ZoneId.systemDefault()).toLocalDate() + } catch (_: Exception) { + LocalDate.parse(iso.substringBefore('T')) + } + ChronoUnit.DAYS.between(date, LocalDate.now()) +}.getOrDefault(Long.MAX_VALUE) + +fun getStreak(history: List): Int { + if (history.isEmpty()) return 0 + val days = history.map { it.date.substringBefore('T') }.toSet().sortedDescending() + val today = LocalDate.now().toString() + val yesterday = LocalDate.now().minusDays(1).toString() + if (days.first() != today && days.first() != yesterday) return 0 + var streak = 0 + var prev = LocalDate.parse(days.first()) + for (d in days) { + val cur = LocalDate.parse(d) + val diff = java.time.Duration.between(cur.atStartOfDay(), prev.atStartOfDay()).toDays() + if (diff <= 1) { streak++; prev = cur } else break + } + return streak +} + +fun getWeekKey(dateString: String): String { + val date = LocalDate.parse(dateString.substringBefore('T')) + val fields = WeekFields.ISO + return "${date.get(fields.weekBasedYear())}-W${date.get(fields.weekOfWeekBasedYear()).toString().padStart(2, '0')}" +} + +fun getGoalStreak(history: List, weeklyGoalDays: Int): Int { + val target = max(1, min(7, weeklyGoalDays)) + if (history.isEmpty()) return 0 + val byWeek = linkedMapOf>() + history.forEach { session -> + val weekKey = getWeekKey(session.date) + val dayKey = session.date.substringBefore('T') + byWeek.getOrPut(weekKey) { linkedSetOf() }.add(dayKey) + } + var streak = 0 + byWeek.keys.sortedDescending().forEach { week -> + val count = byWeek[week]?.size ?: 0 + if (count >= target) streak++ else if (streak > 0) return streak + } + return streak +} + +fun countSessionsThisWeek(history: List): Int { + val now = LocalDate.now() + val fields = WeekFields.ISO + val y = now.get(fields.weekBasedYear()) + val w = now.get(fields.weekOfWeekBasedYear()) + return history.mapNotNull { + runCatching { LocalDate.parse(it.date.substringBefore('T')) }.getOrNull() + }.count { d -> d.get(fields.weekBasedYear()) == y && d.get(fields.weekOfWeekBasedYear()) == w } +} + +@Composable +private fun WeeklySummaryCard( + sessions: Int, + sets: Int, + volumeKg: Int, + weightUnit: String, + streak: Int, + onShare: () -> Unit, +) { + val c = useTheme() + val displayVol = if (weightUnit == "lbs") (volumeKg * 2.2046226218).roundToInt() else volumeKg + val volStr = if (displayVol >= 1000) "%.1fk".format(displayVol / 1000.0) else displayVol.toString() + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(c.card) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("WEEKLY SUMMARY", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), letterSpacing = IronLogType.eyebrow.letterSpacing.sp) + TextButton(onClick = onShare) { Text("SHARE", color = c.accent, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Bold) } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(0.dp)) { + SummaryStatPill("Workouts", sessions.toString(), Modifier.weight(1f)) + SummaryStatPill("Sets", sets.toString(), Modifier.weight(1f)) + SummaryStatPill("Volume", "$volStr $weightUnit", Modifier.weight(1f)) + SummaryStatPill("Streak", "$streak d", Modifier.weight(1f)) + } + } +} + +@Composable +private fun SummaryStatPill(label: String, value: String, modifier: Modifier = Modifier) { + val c = useTheme() + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(value, color = c.text, fontWeight = FontWeight.ExtraBold, fontSize = IronLogType.section.fontSize.sp) + Text(label, color = c.muted, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), letterSpacing = 0.5.sp) + } +} + +@Composable +private fun AthleteProfileCard(totalSessions: Int, streak: Int, onOpen: () -> Unit) { + val c = useTheme() + // Tier thresholds: (label, emoji, minSessions, nextTierMin) + val (tier, tierEmoji, tierMin, tierNext) = when { + totalSessions >= 200 -> TierInfo("Elite", "⚡", 200, Int.MAX_VALUE) + totalSessions >= 76 -> TierInfo("Advanced", "🔥", 76, 200) + totalSessions >= 21 -> TierInfo("Intermediate", "💪", 21, 76) + else -> TierInfo("Beginner", "🌱", 0, 21) + } + val xpProgress = if (tierNext == Int.MAX_VALUE) 1f + else ((totalSessions - tierMin).toFloat() / (tierNext - tierMin)).coerceIn(0f, 1f) + + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(c.card) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .clickable(onClick = onOpen) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("ATHLETE PROFILE", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), letterSpacing = IronLogType.eyebrow.letterSpacing.sp) + Text("$tierEmoji $tier", color = c.text, fontWeight = FontWeight.ExtraBold, fontSize = IronLogType.section.fontSize.sp) + Text("$totalSessions sessions · $streak-day streak", color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + } + Text("→", color = c.accent, fontSize = IronLogType.title.fontSize.sp, fontWeight = FontWeight.Bold) + } + // XP progress bar toward next tier + LinearProgressIndicator( + progress = { xpProgress }, + modifier = Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(IronLogRadius.full.dp)), + color = c.accent, + trackColor = c.faint, + ) + if (tierNext != Int.MAX_VALUE) { + Text( + "${totalSessions - tierMin} / ${tierNext - tierMin} sessions to ${ when(tierNext) { 76 -> "Intermediate"; 200 -> "Advanced"; else -> "Elite" } }", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } +} + +private data class TierInfo(val tier: String, val tierEmoji: String, val tierMin: Int, val tierNext: Int) + +private val MILESTONE_LABELS = mapOf( + "MILESTONE_PR" to ("🏆" to "First PR"), + "MILESTONE_LIFT_1T" to ("💪" to "1 Ton Lifted"), + "MILESTONE_LIFT_5T" to ("🔥" to "5 Tons Lifted"), + "MILESTONE_LIFT_10T" to ("⚡" to "10 Tons Lifted"), + "MILESTONE_STREAK_7" to ("🔥" to "7-Day Streak"), + "MILESTONE_STREAK_30" to ("📅" to "30-Day Streak"), + "MILESTONE_STREAK_100" to ("🌟" to "100-Day Streak"), +) + +@Composable +private fun MilestonesCard(unlocked: Set) { + val c = useTheme() + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(c.card) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("MILESTONES", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), letterSpacing = IronLogType.eyebrow.letterSpacing.sp) + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MILESTONE_LABELS.entries.filter { it.key in unlocked }.forEach { (_, pair) -> + val (emoji, label) = pair + Column( + Modifier + .clip(RoundedCornerShape(IronLogRadius.md.dp)) + .background(c.accentSoft) + .border(1.dp, c.accent.copy(alpha = 0.3f), RoundedCornerShape(IronLogRadius.md.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(emoji, fontSize = IronLogType.title.fontSize.sp) + Text(label, color = c.text, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight.SemiBold) + } + } + } + } +} + + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/intelligence/ApexEngineCard.kt b/app/src/main/java/com/ironlog/app/ui/screens/intelligence/ApexEngineCard.kt new file mode 100644 index 0000000..4b53743 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/intelligence/ApexEngineCard.kt @@ -0,0 +1,337 @@ +package com.ironlog.app.ui.screens.intelligence + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.domain.intelligence.GeminiNanoEngine +import com.ironlog.app.domain.intelligence.NanoAvailability +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.UiPlanDay +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import com.valentinilk.shimmer.shimmer +import kotlinx.coroutines.launch + +// ── Process-scoped insight cache ────────────────────────────────────────────── +// Survives navigation (cache lives as long as the process). Cleared only when +// the most-recent workout ID changes — i.e. after a new workout is logged. + +private object ApexCache { + private var seenWorkoutId: String? = null + private val store = mutableMapOf() + + fun invalidateIfNewWorkout(workoutId: String?) { + if (workoutId != seenWorkoutId) { + store.clear() + seenWorkoutId = workoutId + } + } + + operator fun get(tab: ApexTab): String? = store[tab] + operator fun set(tab: ApexTab, value: String) { store[tab] = value } + fun remove(tab: ApexTab) { store.remove(tab) } +} + +// ── Insight tab model ───────────────────────────────────────────────────────── + +private enum class ApexTab(val label: String, val eyebrow: String) { + RECOVERY ("Recovery", "TODAY'S RECOMMENDATION"), + SPLIT ("Split", "WEEKLY SPLIT ADVICE"), + DAY_EVAL ("Day", "SESSION EVALUATION"), + PROGRESSION("Progress", "PROGRESSION INSIGHT"), +} + +// ── Main card ───────────────────────────────────────────────────────────────── + +@Composable +fun ApexEngineCard( + activePlanDay: UiPlanDay?, + history: List, + lastWorkoutId: String?, + goalMode: String, + sessionsPerWeek: Int, + adherencePct: Int, + weeklyGoalDays: Int, + prTrend: String, + readiness: Map, + onSwitchToBuiltin: () -> Unit, + onOpenTrainingIntelligence: () -> Unit, +) { + val c = useTheme() + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var activeTab by remember { mutableStateOf(ApexTab.RECOVERY) } + var isLoading by remember { mutableStateOf(false) } + var currentText by remember { mutableStateOf(null) } + var availability by remember { mutableStateOf(null) } + + // Invalidate cache only when a new workout is logged; navigation-back hits the cache. + ApexCache.invalidateIfNewWorkout(lastWorkoutId) + + LaunchedEffect(Unit) { + availability = GeminiNanoEngine.checkAvailability(context) + } + + LaunchedEffect(activeTab, availability, lastWorkoutId) { + if (availability != NanoAvailability.SUPPORTED) { isLoading = false; return@LaunchedEffect } + val cached = ApexCache[activeTab] + if (cached != null) { currentText = cached; isLoading = false; return@LaunchedEffect } + isLoading = true + currentText = null + val result = fetchInsight(context, activeTab, readiness, history, weeklyGoalDays, goalMode, activePlanDay, prTrend) + ApexCache[activeTab] = result + currentText = result + isLoading = false + } + + val cardBg = c.accent.copy(alpha = 0.08f) + val accentBorder = c.accent.copy(alpha = 0.30f) + + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(cardBg) + .border(1.dp, accentBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // ── Header ──────────────────────────────────────────────────────────── + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.AutoAwesome, + contentDescription = null, + tint = c.accent, + modifier = Modifier.size(14.dp), + ) + Text( + "APEX ENGINE", + color = c.accent, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + } + Text( + "On-Device AI", + color = c.muted, + fontSize = IronLogType.micro.fontSize.sp, + letterSpacing = 0.5.sp, + ) + } + + // ── Tab row ─────────────────────────────────────────────────────────── + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + ApexTab.entries.forEach { tab -> + val selected = tab == activeTab + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(if (selected) c.accent.copy(alpha = 0.18f) else c.surface) + .border(1.dp, if (selected) c.accent else c.faint, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { activeTab = tab } + .padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Text( + tab.label, + color = if (selected) c.accent else c.muted, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + ) + } + } + } + + // ── Insight panel ───────────────────────────────────────────────────── + when { + availability == NanoAvailability.UNSUPPORTED -> { + Text( + "APEX ENGINE is not supported on this device. Switch to Built-in Intelligence below.", + color = c.muted, + fontSize = IronLogType.body.fontSize.sp, + ) + } + availability == NanoAvailability.NEEDS_SYSTEM_UPDATE -> { + Text( + "Gemini Nano hasn't been deployed to your device yet. Check for Google Play system updates, then return here.", + color = c.muted, + fontSize = IronLogType.body.fontSize.sp, + ) + } + availability == NanoAvailability.NEEDS_DOWNLOAD -> { + Text( + "Gemini Nano model needs a one-time download. Go to Settings → Intelligence Engine to download it.", + color = c.muted, + fontSize = IronLogType.body.fontSize.sp, + ) + } + isLoading || currentText == null -> { + Column( + Modifier.shimmer(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + repeat(3) { idx -> + Box( + Modifier + .fillMaxWidth(if (idx == 2) 0.6f else 1f) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(c.faint), + ) + } + } + } + else -> { + AnimatedContent( + targetState = currentText, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "apex_insight", + ) { text -> + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + activeTab.eyebrow, + color = c.muted, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + ) + Text( + text ?: "", + color = c.text, + fontSize = IronLogType.body.fontSize.sp, + lineHeight = IronLogType.body.lineHeight.sp, + ) + } + } + Row( + Modifier + .clickable { + ApexCache.remove(activeTab) + scope.launch { + isLoading = true + currentText = null + val result = fetchInsight(context, activeTab, readiness, history, weeklyGoalDays, goalMode, activePlanDay, prTrend) + ApexCache[activeTab] = result + currentText = result + isLoading = false + } + } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon(Icons.Outlined.Refresh, null, tint = c.muted, modifier = Modifier.size(12.dp)) + Text("Regenerate", color = c.muted, fontSize = IronLogType.micro.fontSize.sp) + } + } + } + + // ── Stats row ───────────────────────────────────────────────────────── + Text( + "$sessionsPerWeek sessions/wk · $adherencePct% adherence · Gemini Nano", + color = c.subtext, + fontSize = IronLogType.micro.fontSize.sp, + ) + + // ── Footer links ────────────────────────────────────────────────────── + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Switch to Built-in →", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier.clickable(onClick = onSwitchToBuiltin), + ) + Text( + "Training Intelligence →", + color = c.accent, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.clickable(onClick = onOpenTrainingIntelligence), + ) + } + } +} + +// ── Private helper ───────────────────────────────────────────────────────────── + +private suspend fun fetchInsight( + context: android.content.Context, + tab: ApexTab, + readiness: Map, + history: List, + weeklyGoalDays: Int, + goalMode: String, + activePlanDay: UiPlanDay?, + prTrend: String, +): String = when (tab) { + ApexTab.RECOVERY -> GeminiNanoEngine.askRecovery(context, readiness) + ApexTab.SPLIT -> GeminiNanoEngine.askSplitSuggestion(context, history, weeklyGoalDays, goalMode) + ApexTab.DAY_EVAL -> { + val exNames = activePlanDay?.exercises?.map { it.name } ?: emptyList() + val dayName = activePlanDay?.name ?: "your next session" + GeminiNanoEngine.askDayEvaluation(context, dayName, exNames, goalMode) + } + ApexTab.PROGRESSION -> { + val lastEx = history.firstOrNull()?.exercises?.firstOrNull() + if (lastEx == null) { + // No history yet — skip the AI call and surface a clear CTA instead. + "Log a session to unlock personalised progression advice." + } else { + val bestSet = lastEx.sets.filter { it.type != "warmup" }.maxByOrNull { it.weight } + GeminiNanoEngine.askProgressionExplanation( + context, + exerciseName = lastEx.name, + recentWeightKg = bestSet?.weight ?: 0.0, + recentReps = bestSet?.reps?.toInt() ?: 0, + trend = prTrend, + ) + } + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/intelligence/CloudAiCard.kt b/app/src/main/java/com/ironlog/app/ui/screens/intelligence/CloudAiCard.kt new file mode 100644 index 0000000..91bf329 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/intelligence/CloudAiCard.kt @@ -0,0 +1,314 @@ +package com.ironlog.app.ui.screens.intelligence + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cloud +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.domain.intelligence.CloudAiEngine +import com.ironlog.app.domain.intelligence.CloudAiKeyStore +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.UiPlanDay +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import com.valentinilk.shimmer.shimmer +import kotlinx.coroutines.launch + +// ── Process-scoped insight cache ────────────────────────────────────────────── +// Survives navigation (cache lives as long as the process). Cleared only when +// the most-recent workout ID changes — i.e. after a new workout is logged. + +private object CloudAiCache { + private var seenWorkoutId: String? = null + private val store = mutableMapOf() + + fun invalidateIfNewWorkout(workoutId: String?) { + if (workoutId != seenWorkoutId) { + store.clear() + seenWorkoutId = workoutId + } + } + + operator fun get(tab: CloudTab): String? = store[tab] + operator fun set(tab: CloudTab, value: String) { store[tab] = value } + fun remove(tab: CloudTab) { store.remove(tab) } +} + +// ── Insight tab model ───────────────────────────────────────────────────────── + +private enum class CloudTab(val label: String, val eyebrow: String) { + RECOVERY ("Recovery", "TODAY'S RECOMMENDATION"), + SPLIT ("Split", "WEEKLY SPLIT ADVICE"), + DAY_EVAL ("Day", "SESSION EVALUATION"), + PROGRESSION("Progress", "PROGRESSION INSIGHT"), +} + +// ── Main card ───────────────────────────────────────────────────────────────── + +@Composable +fun CloudAiCard( + activePlanDay: UiPlanDay?, + history: List, + lastWorkoutId: String?, + goalMode: String, + sessionsPerWeek: Int, + adherencePct: Int, + weeklyGoalDays: Int, + prTrend: String, + readiness: Map, + displayName: String, + modelName: String, + baseUrl: String, + apiFormat: String, + providerPreset: String, + onSwitchToBuiltin: () -> Unit, + onOpenTrainingIntelligence: () -> Unit, +) { + val c = useTheme() + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // Load the API key for the active provider — lives in EncryptedSharedPreferences, not ViewModel state. + val apiKey = remember(providerPreset) { CloudAiKeyStore.load(context, providerPreset) } + val configured = apiKey.isNotBlank() && baseUrl.isNotBlank() && modelName.isNotBlank() + + var activeTab by remember { mutableStateOf(CloudTab.RECOVERY) } + var isLoading by remember { mutableStateOf(false) } + var currentText by remember { mutableStateOf(null) } + + // Invalidate cache only when a new workout is logged; navigation-back hits the cache. + CloudAiCache.invalidateIfNewWorkout(lastWorkoutId) + + LaunchedEffect(activeTab, lastWorkoutId) { + if (!configured) { isLoading = false; return@LaunchedEffect } + val cached = CloudAiCache[activeTab] + if (cached != null) { currentText = cached; isLoading = false; return@LaunchedEffect } + isLoading = true + currentText = null + val result = fetchCloudInsight( + tab = activeTab, + baseUrl = baseUrl, apiKey = apiKey, modelName = modelName, apiFormat = apiFormat, + readiness = readiness, history = history, weeklyGoalDays = weeklyGoalDays, + goalMode = goalMode, activePlanDay = activePlanDay, prTrend = prTrend, + ) + CloudAiCache[activeTab] = result + currentText = result + isLoading = false + } + + val cardBg = c.accent.copy(alpha = 0.08f) + val accentBorder = c.accent.copy(alpha = 0.30f) + + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(cardBg) + .border(1.dp, accentBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // ── Header ──────────────────────────────────────────────────────────── + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Cloud, contentDescription = null, tint = c.accent, modifier = Modifier.size(14.dp)) + Text( + displayName.uppercase(), + color = c.accent, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + } + Text(modelName.ifBlank { "Cloud AI" }, color = c.muted, fontSize = IronLogType.micro.fontSize.sp, letterSpacing = 0.5.sp) + } + + // ── Tab row ─────────────────────────────────────────────────────────── + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + CloudTab.entries.forEach { tab -> + val selected = tab == activeTab + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(if (selected) c.accent.copy(alpha = 0.18f) else c.surface) + .border(1.dp, if (selected) c.accent else c.faint, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { activeTab = tab } + .padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Text( + tab.label, + color = if (selected) c.accent else c.muted, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + ) + } + } + } + + // ── Insight panel ───────────────────────────────────────────────────── + when { + !configured -> { + Text( + "Set up your Cloud AI provider in Settings → Intelligence Engine to see insights.", + color = c.muted, + fontSize = IronLogType.body.fontSize.sp, + ) + } + isLoading || currentText == null -> { + Column(Modifier.shimmer(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + repeat(3) { idx -> + Box( + Modifier + .fillMaxWidth(if (idx == 2) 0.6f else 1f) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(c.faint), + ) + } + } + } + else -> { + AnimatedContent( + targetState = currentText, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "cloud_insight", + ) { text -> + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + activeTab.eyebrow, + color = c.muted, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + ) + Text(text ?: "", color = c.text, fontSize = IronLogType.body.fontSize.sp, lineHeight = IronLogType.body.lineHeight.sp) + } + } + Row( + Modifier + .clickable { + CloudAiCache.remove(activeTab) + scope.launch { + isLoading = true + currentText = null + val result = fetchCloudInsight( + tab = activeTab, + baseUrl = baseUrl, apiKey = apiKey, modelName = modelName, apiFormat = apiFormat, + readiness = readiness, history = history, weeklyGoalDays = weeklyGoalDays, + goalMode = goalMode, activePlanDay = activePlanDay, prTrend = prTrend, + ) + CloudAiCache[activeTab] = result + currentText = result + isLoading = false + } + } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon(Icons.Outlined.Refresh, null, tint = c.muted, modifier = Modifier.size(12.dp)) + Text("Regenerate", color = c.muted, fontSize = IronLogType.micro.fontSize.sp) + } + } + } + + // ── Stats row ───────────────────────────────────────────────────────── + Text( + "$sessionsPerWeek sessions/wk · $adherencePct% adherence · $displayName", + color = c.subtext, + fontSize = IronLogType.micro.fontSize.sp, + ) + + // ── Footer links ────────────────────────────────────────────────────── + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text( + "Switch to Built-in →", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier.clickable(onClick = onSwitchToBuiltin), + ) + Text( + "Training Intelligence →", + color = c.accent, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.clickable(onClick = onOpenTrainingIntelligence), + ) + } + } +} + +// ── Private helper ──────────────────────────────────────────────────────────── + +private suspend fun fetchCloudInsight( + tab: CloudTab, + baseUrl: String, + apiKey: String, + modelName: String, + apiFormat: String, + readiness: Map, + history: List, + weeklyGoalDays: Int, + goalMode: String, + activePlanDay: UiPlanDay?, + prTrend: String, +): String = when (tab) { + CloudTab.RECOVERY -> CloudAiEngine.askRecovery(baseUrl, apiKey, modelName, apiFormat, readiness) + CloudTab.SPLIT -> CloudAiEngine.askSplitSuggestion(baseUrl, apiKey, modelName, apiFormat, history, weeklyGoalDays, goalMode) + CloudTab.DAY_EVAL -> { + val exNames = activePlanDay?.exercises?.map { it.name } ?: emptyList() + val dayName = activePlanDay?.name ?: "your next session" + CloudAiEngine.askDayEvaluation(baseUrl, apiKey, modelName, apiFormat, dayName, exNames, goalMode) + } + CloudTab.PROGRESSION -> { + val lastEx = history.firstOrNull()?.exercises?.firstOrNull() + if (lastEx == null) { + "Log a session to unlock personalised progression advice." + } else { + val bestSet = lastEx.sets.filter { it.type != "warmup" }.maxByOrNull { it.weight } + CloudAiEngine.askProgressionExplanation( + baseUrl, apiKey, modelName, apiFormat, + exerciseName = lastEx.name, + recentWeightKg = bestSet?.weight ?: 0.0, + recentReps = bestSet?.reps?.toInt() ?: 0, + trend = prTrend, + ) + } + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/intelligence/TrainingIntelligenceScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/intelligence/TrainingIntelligenceScreen.kt new file mode 100644 index 0000000..0ec63e2 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/intelligence/TrainingIntelligenceScreen.kt @@ -0,0 +1,493 @@ +package com.ironlog.app.ui.screens.intelligence + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Bedtime +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.SelfImprovement +import androidx.compose.material.icons.outlined.Timeline +import androidx.compose.foundation.BorderStroke +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.TextButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ironlog.app.domain.intelligence.FINE_MUSCLE_TO_RADAR +import com.ironlog.app.domain.intelligence.TrainingIntelligenceEngine +import com.ironlog.app.domain.intelligence.VolumeLandmark +import com.ironlog.app.domain.intelligence.foldContributions +import com.ironlog.app.domain.intelligence.resolveContribution +import com.ironlog.app.ui.model.HistoryExercise +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.UiPlan +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogThemeTokens +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.viewmodel.StatsViewModel +import com.ironlog.app.ui.viewmodel.PlansViewModel +import com.ironlog.app.data.repository.SettingsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import com.ironlog.app.ui.screens.plans.ProgramRules + +private val tiJson = Json { ignoreUnknownKeys = true } + +@Composable +fun TrainingIntelligenceScreen( + vm: StatsViewModel = viewModel(), + plansVm: PlansViewModel = viewModel(), + onBack: () -> Unit = {}, + onStartWorkout: (dayId: String?) -> Unit = {}, + onOpenRecoveryMap: () -> Unit = {}, + onOpenAIPlan: () -> Unit = {}, + onOpenProgramInsights: () -> Unit = {}, +) { + val c = useTheme() + val state by vm.state.collectAsState() + val plans by plansVm.plans.collectAsState() + val activePlan = plans.firstOrNull { it.isActive } + val recommendedDayId = remember(activePlan, state.history) { + recommendedPlanDayId(activePlan, state.history) + } + val activeDraftContribution by produceState(initialValue = emptyMap()) { + value = withContext(Dispatchers.IO) { loadActiveDraftContribution() } + } + val snapshot = remember(state.history, state.personalBests.size, activeDraftContribution) { + TrainingIntelligenceEngine.build(state.history, state.personalBests.size, activeDraftContribution) + } + val deloadRules by produceState(initialValue = null as ProgramRules?, activePlan?.id) { + val planId = activePlan?.id + if (planId == null) { + value = null + return@produceState + } + val loadedRules = withContext(Dispatchers.IO) { + val repo = SettingsRepository() + val raw = repo.getString("program_rules:$planId") + if (raw.isNullOrBlank()) null + else runCatching { tiJson.decodeFromString(raw) }.getOrNull() + } + value = loadedRules + } + val isDeloadWeek = deloadRules?.let { r -> r.deloadEveryWeeks > 0 && r.currentWeek % r.deloadEveryWeeks == 0 } == true + + LazyColumn( + modifier = Modifier.fillMaxSize().background(c.bg).statusBarsPadding().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + ScreenHeader(title = "Training Intelligence", onBack = onBack) + Spacer(Modifier.height(4.dp)) + Text("TRAINING COACH", color = c.accent, fontSize = IronLogType.eyebrow.fontSize.sp, fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), letterSpacing = IronLogType.eyebrow.letterSpacing.sp) + Text("Intelligence", color = c.text, fontSize = IronLogType.title.fontSize.sp, fontWeight = FontWeight(IronLogType.display.fontWeight), lineHeight = IronLogType.title.lineHeight.sp) + } + item { + IntelligenceCard("Quick Actions") { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + IntelQuickAction("Start Workout", Modifier.weight(1f)) { onStartWorkout(recommendedDayId) } + IntelQuickAction("Recovery Map", Modifier.weight(1f), onOpenRecoveryMap) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + IntelQuickAction("Create With AI", Modifier.weight(1f), onOpenAIPlan) + IntelQuickAction("Program Insights", Modifier.weight(1f), onOpenProgramInsights) + } + } + } + item { TodayDirectiveCard(snapshot, c) } + item { + IntelligenceCard("Weekly Volume by Muscle") { + Text("Based on the current ISO week (Mon–today).", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + androidx.compose.foundation.layout.Spacer(Modifier.height(4.dp)) + if (snapshot.setsByMuscle.values.all { it == 0 }) { + Text("No workouts in the last 30 days.", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + } else { + snapshot.volumeLandmarks.forEach { (muscle, landmark) -> + VolumeLandmarkRow(muscle = muscle, landmark = landmark) + } + } + } + } + if (isDeloadWeek) { + item { DeloadRecommendationCard(deloadRules!!, c) } + } + item { + IntelligenceCard("Movement Balance") { + if (snapshot.movementBalance.values.all { it == 0 }) { + Text("No data yet.", color = c.muted) + } else { + snapshot.movementBalance.forEach { (k, pct) -> + Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(k, color = c.text, fontSize = IronLogType.body.fontSize.sp, modifier = Modifier.weight(0.3f)) + Box( + Modifier + .weight(0.55f) + .height(6.dp) + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.faint), + ) { + Box( + Modifier + .fillMaxWidth(pct / 100f) + .height(6.dp) + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.chartPrimary), + ) + } + Text("$pct%", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, modifier = Modifier.weight(0.15f), textAlign = androidx.compose.ui.text.style.TextAlign.End) + } + } + } + } + } + item { + IntelligenceCard("PR Velocity (30D)") { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Outlined.Timeline, contentDescription = null, tint = c.accent) + Text("${snapshot.prLast30}", color = c.accent, fontWeight = FontWeight.Bold, fontSize = IronLogType.title.fontSize.sp) + Text("PRs in last 30 days", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + } + val trendColor = when (snapshot.prTrend) { + "accelerating" -> c.success; "slowing" -> c.danger; else -> c.muted + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + snapshot.prTrend.replaceFirstChar { it.titlecase() }, + color = trendColor, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.Bold, + ) + Text("vs ${snapshot.prPrev30} in prior 30 days", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + item { + IntelligenceCard("Neural Fatigue Indicator") { + val nf = snapshot.neuralFatigue + Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon( + if (nf.isFlagged) Icons.Outlined.Bedtime else Icons.Outlined.Bolt, + contentDescription = null, + tint = if (nf.isFlagged) c.warning else c.success, + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + if (nf.isFlagged) "${nf.consecutiveDays} consecutive heavy days — consider a deload." + else "No consecutive heavy load detected. Readiness looks stable.", + color = c.text, + fontSize = IronLogType.body.fontSize.sp, + ) + if (nf.isFlagged && nf.lastHeavyExercises.isNotEmpty()) { + Text( + nf.lastHeavyExercises.joinToString(", "), + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } + } + if (nf.isFlagged) { + TextButton(onClick = onOpenRecoveryMap, modifier = Modifier.fillMaxWidth()) { + Text( + "VIEW RECOVERY MAP", + color = c.accent, + fontWeight = FontWeight.Bold, + fontSize = IronLogType.eyebrow.fontSize.sp, + letterSpacing = 1.2.sp, + ) + } + } + } + } + item { + IntelligenceCard("Best Performance Window") { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Outlined.SelfImprovement, contentDescription = null, tint = c.accent) + Text(snapshot.bestWindow, color = c.text, fontSize = IronLogType.body.fontSize.sp) + } + } + } + item { + IntelligenceCard("Training Age") { + Text(String.format(java.util.Locale.US, "%.1f years", snapshot.trainingAgeYears), color = c.accent, fontWeight = FontWeight.Bold, fontSize = IronLogType.title.fontSize.sp) + Text(snapshot.trainingAgeLabel, color = c.text, fontSize = IronLogType.body.fontSize.sp, fontWeight = FontWeight(600)) + Text(snapshot.trainingAgeTip, color = c.muted, fontSize = IronLogType.body.fontSize.sp) + } + androidx.compose.foundation.layout.Spacer(Modifier.height(16.dp).navigationBarsPadding()) + } + } +} + +internal fun recommendedPlanDayId(activePlan: UiPlan?, history: List): String? { + val days = activePlan?.days.orEmpty() + if (days.isEmpty()) return null + val dayIds = days.map { it.id } + val lastCompletedDay = history + .sortedByDescending { it.date } + .firstOrNull { it.planDayUid in dayIds } + ?.planDayUid + val lastIndex = dayIds.indexOf(lastCompletedDay) + return if (lastIndex >= 0) dayIds[(lastIndex + 1) % dayIds.size] else dayIds.first() +} + +@Composable +private fun DeloadRecommendationCard(rules: ProgramRules, c: IronLogThemeTokens) { + val setReduction = "~40%" + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = BorderStroke(1.dp, c.warning.copy(alpha = 0.45f)), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "DELOAD WEEK", + color = c.warning, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + Text( + "Week ${rules.currentWeek} — Scheduled deload", + color = c.text, + fontWeight = FontWeight.Bold, + fontSize = IronLogType.section.fontSize.sp, + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("• Reduce load to 60% of working weight", color = c.subtext, fontSize = IronLogType.body.fontSize.sp) + Text("• Reduce working sets by $setReduction (e.g. 3 → 2)", color = c.subtext, fontSize = IronLogType.body.fontSize.sp) + Text("• Keep rep ranges the same", color = c.subtext, fontSize = IronLogType.body.fontSize.sp) + } + } + } +} + +private suspend fun loadActiveDraftContribution(): Map { + val repo = SettingsRepository() + val activeId = repo.getString("active_workout_id").orEmpty() + if (activeId.isBlank()) return emptyMap() + val raw = repo.getString("active_workout_draft_$activeId").orEmpty() + if (raw.isBlank()) return emptyMap() + val accumulator = linkedMapOf("chest" to 0.0, "back" to 0.0, "arms" to 0.0, "shoulders" to 0.0, "legs" to 0.0, "core" to 0.0) + return runCatching { + val json = org.json.JSONObject(raw) + val setLog = json.optJSONObject("setLog") ?: return@runCatching emptyMap() + val keys = setLog.keys() + while (keys.hasNext()) { + val exName = keys.next() + val arr = setLog.optJSONArray(exName) ?: continue + val setCount = (0 until arr.length()).count { i -> + arr.optJSONObject(i)?.optString("type", "normal") != "warmup" + } + if (setCount == 0) continue + val pseudoEx = HistoryExercise( + name = exName, sets = emptyList(), + primaryMuscle = null, primaryMuscles = emptyList(), category = null, + ) + val contrib = resolveContribution(pseudoEx) + if (contrib.isNotEmpty()) { + val radarFold = foldContributions(contrib, FINE_MUSCLE_TO_RADAR) + radarFold.forEach { (bucket, frac) -> + val key = bucket.lowercase() + if (key in accumulator) accumulator[key] = accumulator.getValue(key) + setCount * frac + } + } else { + val lower = exName.lowercase() + val bucket = when { + lower.contains("chest") || lower.contains("pec") -> "chest" + lower.contains("back") || lower.contains("row") || lower.contains("lat") -> "back" + lower.contains("bicep") || lower.contains("tricep") || lower.contains("curl") -> "arms" + lower.contains("delt") || lower.contains("shoulder") -> "shoulders" + lower.contains("squat") || lower.contains("deadlift") || lower.contains("leg") || + lower.contains("hamstring") || lower.contains("glute") || lower.contains("calf") -> "legs" + else -> "core" + } + accumulator[bucket] = accumulator.getValue(bucket) + setCount + } + } + accumulator.mapValues { it.value.toInt() } + }.getOrDefault(emptyMap()) +} + +@Composable +private fun TodayDirectiveCard( + snapshot: com.ironlog.app.domain.intelligence.TrainingIntelligenceSnapshot, + c: com.ironlog.app.ui.theme.IronLogThemeTokens, +) { + // Build directive from snapshot data + val (directive, reason) = remember(snapshot) { + when { + snapshot.neuralFatigue.isFlagged -> + "Rest or light cardio today" to + "${snapshot.neuralFatigue.consecutiveDays} consecutive heavy days detected — recovery first." + snapshot.volumeLandmarks.any { (_, lm) -> lm.status == "sub_mev" } -> { + val lagging = snapshot.volumeLandmarks.filter { (_, lm) -> lm.status == "sub_mev" }.keys.firstOrNull() ?: "a muscle group" + "Train $lagging today" to "Volume is below minimum effective dose — add a session this week." + } + snapshot.prTrend == "slowing" -> + "Focus on progressive overload this week" to + "PR velocity is slowing vs prior 30 days — push harder on compound lifts." + else -> + "You're on track — train as planned" to + "Volume and recovery indicators are within healthy ranges." + } + } + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = BorderStroke(1.dp, c.accentBorder), + shape = androidx.compose.foundation.shape.RoundedCornerShape(com.ironlog.app.ui.theme.IronLogRadius.xl.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + "TODAY", + color = c.accent, + fontSize = com.ironlog.app.ui.theme.IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(com.ironlog.app.ui.theme.IronLogType.eyebrow.fontWeight), + letterSpacing = com.ironlog.app.ui.theme.IronLogType.eyebrow.letterSpacing.sp, + ) + Text( + directive, + color = c.text, + fontSize = com.ironlog.app.ui.theme.IronLogType.section.fontSize.sp, + fontWeight = FontWeight.Bold, + ) + Text( + reason, + color = c.subtext, + fontSize = com.ironlog.app.ui.theme.IronLogType.body.fontSize.sp, + ) + } + } +} + +@Composable +private fun VolumeLandmarkRow(muscle: String, landmark: VolumeLandmark) { + val c = useTheme() + val statusColor = when (landmark.status) { + "optimal" -> c.success + "high" -> c.warning + else -> c.muted + } + val barMax = (landmark.max + 4).coerceAtLeast(landmark.sets + 2) + val optMinFrac = landmark.min.toFloat() / barMax + val optMaxFrac = landmark.max.toFloat() / barMax + val fillFrac = (landmark.sets.toFloat() / barMax).coerceIn(0f, 1f) + + Column(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(muscle, color = c.text, fontSize = IronLogType.body.fontSize.sp, fontWeight = FontWeight(600)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Text("${landmark.sets} sets", color = c.text, fontSize = IronLogType.meta.fontSize.sp) + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.xs.dp)) + .background(statusColor.copy(alpha = 0.15f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text(landmark.status.uppercase(), color = statusColor, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight.Black, letterSpacing = 0.8.sp) + } + } + } + // Zone bar: track → optimal zone highlight → fill bar + Box(Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(IronLogRadius.full.dp)).background(c.faint)) { + // Green optimal zone backdrop + Box( + Modifier + .fillMaxWidth() + .height(8.dp), + ) { + androidx.compose.foundation.Canvas(Modifier.fillMaxWidth().height(8.dp)) { + val zoneLeft = optMinFrac * size.width + val zoneWidth = (optMaxFrac - optMinFrac) * size.width + drawRect( + color = c.success.copy(alpha = 0.12f), + topLeft = androidx.compose.ui.geometry.Offset(zoneLeft, 0f), + size = androidx.compose.ui.geometry.Size(zoneWidth, size.height), + ) + } + } + // Filled sets bar + Box( + Modifier + .fillMaxWidth(fillFrac) + .height(8.dp) + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(statusColor.copy(alpha = 0.75f)), + ) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("MEV ${landmark.min}", color = c.muted, fontSize = IronLogType.micro.fontSize.sp) + Text("Target ${landmark.optimal}", color = c.accent, fontSize = IronLogType.micro.fontSize.sp) + Text("MRV ${landmark.max}", color = c.muted, fontSize = IronLogType.micro.fontSize.sp) + } + } +} + +@Composable +private fun IntelQuickAction(label: String, modifier: Modifier, onClick: () -> Unit) { + val c = useTheme() + Box( + modifier = modifier + .background(c.surface, RoundedCornerShape(IronLogRadius.md.dp)) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.md.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 13.dp), + contentAlignment = Alignment.Center, + ) { + Text( + label, + color = c.accent, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight(IronLogType.button.fontWeight), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } +} + +@Composable +private fun IntelligenceCard(title: String, content: @Composable () -> Unit) { + val c = useTheme() + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = BorderStroke(1.dp, c.cardBorder), + shape = RoundedCornerShape(IronLogRadius.lg.dp), + ) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, color = c.text, fontSize = IronLogType.section.fontSize.sp, fontWeight = FontWeight(IronLogType.section.fontWeight)) + content() + } + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingComponents.kt b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingComponents.kt new file mode 100644 index 0000000..1a3380a --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingComponents.kt @@ -0,0 +1,168 @@ +package com.ironlog.app.ui.screens.onboarding + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay + +@Composable +fun ParticleField( + modifier: Modifier = Modifier, + @Suppress("UNUSED_PARAMETER") count: Int = OnboardingConfig.PARTICLE_COUNT_DRIFT, +) { + Canvas( + modifier = modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf( + OnboardingConfig.bgDark, + OnboardingConfig.bgDark.copy(alpha = 0.96f), + OnboardingConfig.surfaceDark.copy(alpha = 0.42f), + ), + ), + ), + ) { + drawCircle( + color = OnboardingConfig.accentBlue.copy(alpha = 0.10f), + radius = size.minDimension * 0.55f, + center = Offset(size.width * 0.15f, size.height * 0.12f), + ) + drawCircle( + color = OnboardingConfig.accentGold.copy(alpha = 0.07f), + radius = size.minDimension * 0.48f, + center = Offset(size.width * 0.92f, size.height * 0.88f), + ) + } +} + +@Composable +fun TypewriterText( + text: String, + modifier: Modifier = Modifier, + color: Color = OnboardingConfig.textMuted, + fontSize: Int = 14, + delayMs: Long = OnboardingConfig.TYPEWRITER_DELAY_MS, + onComplete: () -> Unit = {}, +) { + var displayed by remember(text) { mutableStateOf("") } + LaunchedEffect(text) { + displayed = "" + text.forEach { char -> + delay(delayMs) + displayed += char + } + onComplete() + } + Text( + text = displayed, + color = color, + fontSize = fontSize.sp, + modifier = modifier, + textAlign = TextAlign.Center, + ) +} + +@Composable +fun SetupBadge( + code: String, + accent: Color, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .background(accent.copy(alpha = 0.14f), RoundedCornerShape(999.dp)) + .border(1.dp, accent.copy(alpha = 0.64f), RoundedCornerShape(999.dp)) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text( + text = code, + color = accent, + fontSize = 12.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 1.sp, + ) + } +} + +@Composable +fun GlowButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + color: Color = OnboardingConfig.accentBlue, +) { + val alpha by animateFloatAsState(if (enabled) 1f else 0.44f, label = "btnAlpha") + Button( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(22.dp), + colors = ButtonDefaults.buttonColors(containerColor = color.copy(alpha = alpha)), + modifier = modifier + .fillMaxWidth() + .height(56.dp), + ) { + Text( + text = text, + fontWeight = FontWeight.ExtraBold, + fontSize = 14.sp, + letterSpacing = 0.8.sp, + color = Color(0xFF17131E).copy(alpha = alpha), + ) + } +} + +@Composable +fun GlowCard( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + glowColor: Color = OnboardingConfig.accentBlue, + content: @Composable ColumnScope.() -> Unit, +) { + val borderColor by animateColorAsState( + targetValue = if (selected) glowColor.copy(alpha = 0.78f) else OnboardingConfig.cardBorder, + animationSpec = tween(240), + label = "cardBorder", + ) + val bgAlpha by animateFloatAsState(if (selected) 0.16f else 0.02f, label = "cardBg") + Column( + modifier = modifier + .background(glowColor.copy(alpha = bgAlpha), RoundedCornerShape(24.dp)) + .border(1.dp, borderColor, RoundedCornerShape(24.dp)) + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(18.dp), + content = content, + ) +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingConfig.kt b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingConfig.kt new file mode 100644 index 0000000..2a8f094 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingConfig.kt @@ -0,0 +1,121 @@ +package com.ironlog.app.ui.screens.onboarding + +import androidx.compose.ui.graphics.Color + +object OnboardingConfig { + + val accentBlue = Color(0xFFC7B6FF) + val accentGold = Color(0xFFD8C6A1) + val bgDark = Color(0xFF111018) + val surfaceDark = Color(0xFF1D1B24) + val cardBorder = Color(0xFF393441) + val textMuted = Color(0xFFB9B0C8) + val grantedColor = Color(0xFF26A69A) + val deniedColor = Color(0xFFEF5350) + + enum class AiProvider(val key: String, val displayName: String, val baseUrl: String, val apiFormat: String) { + GEMINI("gemini", "Google Gemini", "https://generativelanguage.googleapis.com/v1beta/openai", "openai"), + OPENAI("openai", "OpenAI", "https://api.openai.com/v1", "openai"), + CLAUDE("claude", "Claude", "https://api.anthropic.com", "anthropic"), + DEEPSEEK("deepseek", "DeepSeek", "https://api.deepseek.com", "openai"), + KIMI("kimi", "Kimi", "https://api.moonshot.ai/v1", "openai"), + OPENROUTER("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai"), + CUSTOM("custom", "Custom", "", "openai"), + } + + data class AiModelOption( + val modelId: String, + val displayName: String, + val subtitle: String, + ) + + val geminiModels = listOf( + AiModelOption("gemini-2.0-flash-lite", "Gemini 2.0 Flash-Lite", "Recommended free tier"), + AiModelOption("gemini-2.0-flash", "Gemini 2.0 Flash", "Free, fast, 1M context"), + AiModelOption("gemini-2.5-flash", "Gemini 2.5 Flash", "Fast, stronger reasoning"), + AiModelOption("gemini-2.5-pro", "Gemini 2.5 Pro", "Best reasoning"), + ) + + val openAiModels = listOf( + AiModelOption("gpt-4o-mini", "GPT-4o Mini", "Fast and cost-efficient"), + AiModelOption("gpt-4o", "GPT-4o", "Most capable"), + ) + + val claudeModels = listOf( + AiModelOption("claude-3-5-haiku-latest", "Claude 3.5 Haiku", "Fast plan drafting"), + AiModelOption("claude-3-5-sonnet-latest", "Claude 3.5 Sonnet", "Stronger reasoning"), + ) + + val deepSeekModels = listOf( + AiModelOption("deepseek-chat", "DeepSeek Chat", "Low-cost structured output"), + AiModelOption("deepseek-reasoner", "DeepSeek Reasoner", "More deliberate planning"), + ) + + val kimiModels = listOf( + AiModelOption("moonshot-v1-8k", "Kimi 8K", "Fast"), + AiModelOption("moonshot-v1-32k", "Kimi 32K", "Longer plans"), + AiModelOption("moonshot-v1-128k", "Kimi 128K", "Large context"), + ) + + val openRouterModels = listOf( + AiModelOption("google/gemini-2.0-flash-001", "Gemini 2.0 Flash", "OpenRouter"), + AiModelOption("anthropic/claude-3.5-sonnet", "Claude 3.5 Sonnet", "OpenRouter"), + AiModelOption("openai/gpt-4o-mini", "GPT-4o Mini", "OpenRouter"), + ) + + fun modelsFor(provider: AiProvider): List = when (provider) { + AiProvider.GEMINI -> geminiModels + AiProvider.OPENAI -> openAiModels + AiProvider.CLAUDE -> claudeModels + AiProvider.DEEPSEEK -> deepSeekModels + AiProvider.KIMI -> kimiModels + AiProvider.OPENROUTER -> openRouterModels + AiProvider.CUSTOM -> emptyList() + } + + fun defaultModelFor(provider: AiProvider): String = when (provider) { + AiProvider.GEMINI -> "gemini-2.0-flash-lite" + AiProvider.OPENAI -> "gpt-4o-mini" + AiProvider.CLAUDE -> "claude-3-5-haiku-latest" + AiProvider.DEEPSEEK -> "deepseek-chat" + AiProvider.KIMI -> "moonshot-v1-32k" + AiProvider.OPENROUTER -> "google/gemini-2.0-flash-001" + AiProvider.CUSTOM -> "" + } + + fun providerFor(key: String): AiProvider = + AiProvider.entries.firstOrNull { it.key == key.lowercase() || it.name == key.uppercase() } ?: AiProvider.GEMINI + + fun isKeyFormatValid(provider: AiProvider, key: String): Boolean = key.trim().length >= 12 + + const val AI_STUDIO_URL = "https://aistudio.google.com/app/apikey" + + data class BaselineOption( + val code: String, + val label: String, + val description: String, + val progressionStyle: String, + val defaultGoalMode: String, + val accent: Color, + ) + + val baselineOptions = listOf( + BaselineOption("01", "Rebuilding", "New or returning to training", "LINEAR", "STRENGTH", Color(0xFFB8B2C7)), + BaselineOption("02", "Consistent", "Training regularly for months", "DOUBLE_PROGRESSION", "HYPERTROPHY", accentBlue), + BaselineOption("03", "Experienced", "Years of structured work", "UNDULATING", "PERFORMANCE", accentGold), + ) + + enum class OnboardingPermission { CAMERA, HEALTH_CONNECT, NOTIFICATIONS } + + val permissionOrder = listOf( + OnboardingPermission.CAMERA, + OnboardingPermission.HEALTH_CONNECT, + OnboardingPermission.NOTIFICATIONS, + ) + + const val PARTICLE_COUNT_DRIFT = 0 + const val PARTICLE_COUNT_BURST = 0 + const val TYPEWRITER_DELAY_MS = 40L + const val SCREEN1_AUTO_ADVANCE_MS = 3000L + const val COMPLETION_REVEAL_DELAY_MS = 3000L +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingLedgerPreview.kt b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingLedgerPreview.kt new file mode 100644 index 0000000..771a3dc --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingLedgerPreview.kt @@ -0,0 +1,37 @@ +package com.ironlog.app.ui.screens.onboarding + +import com.ironlog.app.domain.gamification.AthleteCalibration +import com.ironlog.app.domain.gamification.IronLedgerEngine +import com.ironlog.app.domain.gamification.IronLedgerSnapshot +import com.ironlog.app.domain.gamification.IronLedgerStats + +internal fun onboardingCalibrationFromDraft(draft: OnboardingDraft): AthleteCalibration = AthleteCalibration( + trainingAgeMonths = draft.trainingAgeMonths.coerceAtLeast(0), + historicalTrainingDaysPerWeek = draft.historicalTrainingDaysPerWeek.coerceIn(1, 7), + weeklyGoalDays = draft.weeklyGoalDays.coerceIn(1, 7), + bodyweightKg = draft.bodyweightKg.takeIf { it > 0 }?.toDouble(), + hasPastTraining = draft.hasPastTraining, + hasGymAccess = draft.hasGymAccess, + baselinePushups = draft.baselinePushups.coerceAtLeast(0), + baselinePullups = draft.baselinePullups.coerceAtLeast(0), + baselineBenchKg = draft.baselineBenchKg.coerceAtLeast(0), + baselineLatPulldownKg = draft.baselineLatPulldownKg.coerceAtLeast(0), + baselineMileRunSeconds = draft.baselineMileRunSeconds.coerceAtLeast(0), +) + +internal fun buildOnboardingLedgerSnapshot(draft: OnboardingDraft): IronLedgerSnapshot = + IronLedgerEngine().rebuild( + history = emptyList(), + weeklyGoal = draft.weeklyGoalDays.coerceIn(1, 7), + calibration = onboardingCalibrationFromDraft(draft), + ) + +internal fun onboardingPreviewStats(stats: IronLedgerStats): Map = linkedMapOf( + "STR" to stats.strength, + "PWR" to stats.power, + "HYP" to stats.hypertrophy, + "END" to stats.endurance, + "AGI" to stats.agility, + "DISC" to stats.discipline, + "REC" to stats.recovery, +) diff --git a/app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..9c04f0b --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingViewModel.kt @@ -0,0 +1,112 @@ +package com.ironlog.app.ui.screens.onboarding + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class OnboardingDraft( + val userName: String = "", + val progressionStyle: String = "LINEAR", + val goalMode: String = "STRENGTH", + val weeklyGoalDays: Int = 3, + val historicalTrainingDaysPerWeek: Int = 3, + val selectedDayIndices: Set = setOf(0, 2, 4), + val weightUnit: String = "kg", + val cloudAiApiKey: String = "", + val cloudAiModelName: String = "gemini-2.0-flash-lite", + val cloudAiProviderPreset: String = "gemini", + val intelligenceMode: String = "LOCAL", + val cameraGranted: Boolean = false, + val healthConnectGranted: Boolean = false, + val notificationsGranted: Boolean = false, + // Step 3 — Baseline calibration fields + val yearOfBirth: Int = 2000, + val bodyweightKg: Int = 70, + val trainingAgeMonths: Int = 6, + val hasPastTraining: Boolean = false, + val hasGymAccess: Boolean = true, + val baselinePushups: Int = 0, + val baselinePullups: Int = 0, + val baselineBenchKg: Int = 0, + val baselineLatPulldownKg: Int = 0, + val baselineMileRunSeconds: Int = 0, +) + +class OnboardingViewModel : ViewModel() { + + private val _draft = MutableStateFlow(OnboardingDraft()) + val draft: StateFlow = _draft.asStateFlow() + + fun updateUserName(name: String) { + _draft.update { it.copy(userName = name.take(30)) } + } + + fun updateWeeklyGoalDays(days: Int) { + _draft.update { it.copy(weeklyGoalDays = days.coerceIn(1, 7)) } + } + + fun updateHistoricalTrainingDaysPerWeek(days: Int) { + _draft.update { it.copy(historicalTrainingDaysPerWeek = days.coerceIn(1, 7)) } + } + + fun toggleDayIndex(index: Int) { + val current = _draft.value.selectedDayIndices + val updated = if (index in current && current.size > 1) current - index else current + index + _draft.update { it.copy(selectedDayIndices = updated, weeklyGoalDays = updated.size) } + } + + fun updateWeightUnit(unit: String) { + _draft.update { it.copy(weightUnit = unit) } + } + + fun setClassification(progressionStyle: String, defaultGoalMode: String) { + _draft.update { it.copy(progressionStyle = progressionStyle, goalMode = defaultGoalMode) } + } + + fun setGoalMode(mode: String) { + _draft.update { it.copy(goalMode = mode) } + } + + fun updateCloudProvider(provider: String, defaultModel: String) { + _draft.update { it.copy(cloudAiProviderPreset = provider.lowercase(), cloudAiModelName = defaultModel) } + } + + fun updateCloudApiKey(key: String) { + _draft.update { + it.copy( + cloudAiApiKey = key.trim(), + intelligenceMode = if (key.isNotBlank()) "AUTO" else "LOCAL", + ) + } + } + + fun updateCloudModelName(model: String) { + _draft.update { it.copy(cloudAiModelName = model) } + } + + fun setCameraGranted(granted: Boolean) { + _draft.update { it.copy(cameraGranted = granted) } + } + + fun setHealthConnectGranted(granted: Boolean) { + _draft.update { it.copy(healthConnectGranted = granted) } + } + + fun setNotificationsGranted(granted: Boolean) { + _draft.update { it.copy(notificationsGranted = granted) } + } + + // Baseline calibration update methods + fun updateYearOfBirth(year: Int) { _draft.update { it.copy(yearOfBirth = year) } } + fun updateBodyweightKg(kg: Int) { _draft.update { it.copy(bodyweightKg = kg) } } + fun updateTrainingAgeMonths(months: Int) { _draft.update { it.copy(trainingAgeMonths = months) } } + fun setHasPastTraining(has: Boolean) { _draft.update { it.copy(hasPastTraining = has) } } + fun setHasGymAccess(has: Boolean) { _draft.update { it.copy(hasGymAccess = has) } } + fun updateBaselinePushups(n: Int) { _draft.update { it.copy(baselinePushups = n) } } + fun updateBaselinePullups(n: Int) { _draft.update { it.copy(baselinePullups = n) } } + fun updateBaselineBenchKg(kg: Int) { _draft.update { it.copy(baselineBenchKg = kg) } } + fun updateBaselineLatPulldownKg(kg: Int) { _draft.update { it.copy(baselineLatPulldownKg = kg) } } + fun updateBaselineMileRunSeconds(seconds: Int) { _draft.update { it.copy(baselineMileRunSeconds = seconds) } } +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step1Awakening.kt b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step1Awakening.kt new file mode 100644 index 0000000..d8a6365 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step1Awakening.kt @@ -0,0 +1,84 @@ +package com.ironlog.app.ui.screens.onboarding.steps + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.R +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig +import com.ironlog.app.ui.screens.onboarding.ParticleField +import kotlinx.coroutines.delay + +@Composable +fun Step1Awakening(onAdvance: () -> Unit) { + var logoVisible by remember { mutableStateOf(false) } + val logoAlpha by animateFloatAsState( + targetValue = if (logoVisible) 1f else 0f, + animationSpec = tween(1200, easing = FastOutSlowInEasing), + label = "logoAlpha", + ) + + LaunchedEffect(Unit) { + logoVisible = true + delay(OnboardingConfig.SCREEN1_AUTO_ADVANCE_MS) + onAdvance() + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark), + ) { + ParticleField() + + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "IRONLOG", + color = OnboardingConfig.accentBlue, + fontSize = 36.sp, + fontWeight = FontWeight.Black, + letterSpacing = 8.sp, + modifier = Modifier.alpha(logoAlpha), + ) + + Spacer(Modifier.height(48.dp)) + + Text( + text = stringResource(R.string.onb_awakening_line1), + color = OnboardingConfig.textMuted, + fontSize = 15.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 32.dp), + ) + Spacer(Modifier.height(18.dp)) + LinearProgressIndicator( + color = OnboardingConfig.accentBlue, + trackColor = OnboardingConfig.cardBorder, + modifier = Modifier + .fillMaxWidth(0.42f) + .height(4.dp), + ) + Spacer(Modifier.height(14.dp)) + Text( + text = stringResource(R.string.onb_awakening_line2), + color = OnboardingConfig.textMuted.copy(alpha = 0.78f), + fontSize = 13.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step2Registration.kt b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step2Registration.kt new file mode 100644 index 0000000..a554911 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step2Registration.kt @@ -0,0 +1,95 @@ +package com.ironlog.app.ui.screens.onboarding.steps + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.R +import com.ironlog.app.ui.screens.onboarding.GlowButton +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig + +@Composable +fun Step2Registration( + userName: String, + onUserNameChange: (String) -> Unit, + onNext: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + Box( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark) + .padding(horizontal = 32.dp), + ) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.onb_reg_header), + color = OnboardingConfig.accentBlue, + fontSize = 22.sp, + fontWeight = FontWeight.Black, + letterSpacing = 4.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.onb_reg_subtext), + color = OnboardingConfig.textMuted, + fontSize = 14.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(40.dp)) + + OutlinedTextField( + value = userName, + onValueChange = { if (it.length <= 30) onUserNameChange(it) }, + placeholder = { Text(stringResource(R.string.onb_reg_hint), color = OnboardingConfig.textMuted) }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = OnboardingConfig.accentBlue, + unfocusedBorderColor = OnboardingConfig.accentBlue.copy(alpha = 0.3f), + cursorColor = OnboardingConfig.accentBlue, + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + ), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { if (userName.isNotBlank()) onNext() }), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) + + Spacer(Modifier.height(40.dp)) + + GlowButton( + text = stringResource(R.string.onb_reg_cta), + onClick = onNext, + enabled = userName.isNotBlank(), + ) + } + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step3Baseline.kt b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step3Baseline.kt new file mode 100644 index 0000000..229b692 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step3Baseline.kt @@ -0,0 +1,355 @@ +package com.ironlog.app.ui.screens.onboarding.steps + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.ui.screens.onboarding.GlowButton +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig +import java.time.Year +import kotlin.math.roundToInt + +private data class PickerSpec( + val title: String, + val values: List, + val selected: Int, + val labelFor: (Int) -> String, + val onSelect: (Int) -> Unit, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Step3Baseline( + yearOfBirth: Int, + bodyweightKg: Int, + trainingAgeMonths: Int, + historicalTrainingDaysPerWeek: Int, + hasPastTraining: Boolean, + hasGymAccess: Boolean, + pushups: Int, + pullups: Int, + benchKg: Int, + latPulldownKg: Int, + mileRunSeconds: Int, + onYearOfBirthChange: (Int) -> Unit, + onBodyweightChange: (Int) -> Unit, + onTrainingAgeChange: (Int) -> Unit, + onHistoricalTrainingDaysPerWeekChange: (Int) -> Unit, + onPastTrainingChange: (Boolean) -> Unit, + onGymAccessChange: (Boolean) -> Unit, + onPushupsChange: (Int) -> Unit, + onPullupsChange: (Int) -> Unit, + onBenchChange: (Int) -> Unit, + onLatPulldownChange: (Int) -> Unit, + onMileRunChange: (Int) -> Unit, + seededGrade: String, + seededStats: Map, + onNext: () -> Unit, +) { + var picker by remember { mutableStateOf(null) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + Column( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Set your baseline", + color = OnboardingConfig.accentBlue, + fontSize = 28.sp, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + "This only sets your starting point. Verified workouts decide real progression.", + color = OnboardingConfig.textMuted, + fontSize = 14.sp, + lineHeight = 20.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + + BaselineCard("Profile") { + PickerField("Birth year", "$yearOfBirth · ${ageFromBirthYear(yearOfBirth)} yrs") { + picker = PickerSpec( + title = "Birth year", + values = (1940..(Year.now().value - 13)).toList().reversed(), + selected = yearOfBirth, + labelFor = { it.toString() }, + onSelect = onYearOfBirthChange, + ) + } + PickerField("Body weight", "$bodyweightKg kg") { + picker = PickerSpec( + title = "Body weight", + values = (35..180).toList(), + selected = bodyweightKg, + labelFor = { "$it kg" }, + onSelect = onBodyweightChange, + ) + } + PickerField("Training age", formatTrainingAge(trainingAgeMonths)) { + picker = PickerSpec( + title = "Training age", + values = trainingAgeValues(), + selected = trainingAgeMonths, + labelFor = ::formatTrainingAge, + onSelect = onTrainingAgeChange, + ) + } + PickerField("Average training days / week", formatHistoricalTrainingDays(historicalTrainingDaysPerWeek)) { + picker = PickerSpec( + title = "Average training days / week", + values = (1..7).toList(), + selected = historicalTrainingDaysPerWeek, + labelFor = ::formatHistoricalTrainingDays, + onSelect = onHistoricalTrainingDaysPerWeekChange, + ) + } + } + + ToggleRow("Logged workouts before?", hasPastTraining, onPastTrainingChange) + ToggleRow("Gym equipment access?", hasGymAccess, onGymAccessChange) + + BaselineCard("Movement checks") { + PickerField("Pushups", "$pushups reps") { + picker = PickerSpec("Pushups", (0..120).toList(), pushups, { "$it reps" }, onPushupsChange) + } + PickerField("Pullups", "$pullups reps") { + picker = PickerSpec("Pullups", (0..40).toList(), pullups, { "$it reps" }, onPullupsChange) + } + PickerField("1-mile run", formatMileRun(mileRunSeconds)) { + picker = PickerSpec( + title = "1-mile run", + values = listOf(0) + (240..900 step 15).toList(), + selected = mileRunSeconds, + labelFor = ::formatMileRun, + onSelect = onMileRunChange, + ) + } + if (hasGymAccess) { + PickerField("Bench press", "$benchKg kg") { + picker = PickerSpec("Bench press", (0..220 step 5).toList(), benchKg, { "$it kg" }, onBenchChange) + } + PickerField("Lat pulldown", "$latPulldownKg kg") { + picker = PickerSpec("Lat pulldown", (0..220 step 5).toList(), latPulldownKg, { "$it kg" }, onLatPulldownChange) + } + } + } + + BaselineResultCard(grade = seededGrade, stats = seededStats) + + Spacer(Modifier.height(24.dp)) + GlowButton(text = "Save baseline", onClick = onNext) + Spacer(Modifier.height(24.dp)) + } + + picker?.let { active -> + ModalBottomSheet( + onDismissRequest = { picker = null }, + sheetState = sheetState, + containerColor = OnboardingConfig.surfaceDark, + contentColor = Color.White, + ) { + Text( + active.title, + color = Color.White, + fontSize = 22.sp, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), + ) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .height(360.dp) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(active.values) { value -> + val selected = value == active.selected + Surface( + color = if (selected) OnboardingConfig.accentBlue.copy(alpha = 0.18f) else OnboardingConfig.bgDark.copy(alpha = 0.55f), + shape = RoundedCornerShape(18.dp), + border = BorderStroke(1.dp, if (selected) OnboardingConfig.accentBlue else OnboardingConfig.cardBorder), + modifier = Modifier + .fillMaxWidth() + .clickable { + active.onSelect(value) + picker = null + }, + ) { + Text( + active.labelFor(value), + color = if (selected) OnboardingConfig.accentBlue else Color.White, + fontWeight = if (selected) FontWeight.ExtraBold else FontWeight.SemiBold, + fontSize = if (selected) 22.sp else 18.sp, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + textAlign = TextAlign.Center, + ) + } + } + } + Spacer(Modifier.height(24.dp)) + } + } +} + +@Composable +private fun BaselineCard(title: String, content: @Composable () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 14.dp) + .background(OnboardingConfig.surfaceDark, RoundedCornerShape(24.dp)) + .border(1.dp, OnboardingConfig.cardBorder, RoundedCornerShape(24.dp)) + .padding(18.dp), + ) { + Text(title, color = OnboardingConfig.textMuted, fontSize = 13.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(12.dp)) + content() + } +} + +@Composable +private fun PickerField(label: String, value: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .background(OnboardingConfig.bgDark.copy(alpha = 0.45f), RoundedCornerShape(18.dp)) + .border(1.dp, OnboardingConfig.cardBorder, RoundedCornerShape(18.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 15.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, color = OnboardingConfig.textMuted, fontSize = 14.sp, modifier = Modifier.weight(1f)) + Text(value, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.ExtraBold, textAlign = TextAlign.End) + } +} + +@Composable +private fun ToggleRow(label: String, selected: Boolean, onChange: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, color = OnboardingConfig.textMuted, modifier = Modifier.weight(1f), fontSize = 14.sp) + ToggleChip("No", !selected) { onChange(false) } + ToggleChip("Yes", selected) { onChange(true) } + } +} + +@Composable +private fun ToggleChip(label: String, selected: Boolean, onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + shape = RoundedCornerShape(999.dp), + border = BorderStroke(1.dp, if (selected) OnboardingConfig.accentBlue else OnboardingConfig.cardBorder), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (selected) OnboardingConfig.accentBlue.copy(alpha = 0.18f) else Color.Transparent, + contentColor = if (selected) OnboardingConfig.accentBlue else OnboardingConfig.textMuted, + ), + contentPadding = ButtonDefaults.ContentPadding, + ) { + Text(label, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun BaselineResultCard(grade: String, stats: Map) { + BaselineCard("Starting estimate") { + Text( + grade, + color = OnboardingConfig.accentBlue, + fontSize = 24.sp, + fontWeight = FontWeight.Black, + ) + Spacer(Modifier.height(6.dp)) + Text( + "Self-reported values are capped. Higher grades require verified sessions, consistency, and integrity.", + color = OnboardingConfig.textMuted, + fontSize = 13.sp, + lineHeight = 18.sp, + ) + Spacer(Modifier.height(14.dp)) + stats.entries.chunked(2).forEach { row -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + row.forEach { (label, value) -> + Column( + modifier = Modifier + .weight(1f) + .background(OnboardingConfig.bgDark.copy(alpha = 0.45f), RoundedCornerShape(16.dp)) + .padding(12.dp), + ) { + Text(label, color = OnboardingConfig.textMuted, fontSize = 11.sp, fontWeight = FontWeight.Bold) + Text(value.toString(), color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Black) + } + } + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + Spacer(Modifier.height(10.dp)) + } + } +} + +private fun ageFromBirthYear(year: Int): Int = (Year.now().value - year).coerceIn(13, 90) + +private fun trainingAgeValues(): List = + (0..24).toList() + (30..120 step 6).toList() + (132..360 step 12).toList() + +private fun formatTrainingAge(months: Int): String = when { + months <= 0 -> "New" + months < 12 -> "$months mo" + months % 12 == 0 -> "${months / 12} yr" + else -> "${months / 12}y ${months % 12}m" +} + +private fun formatHistoricalTrainingDays(days: Int): String = + if (days == 1) "1 day / week" else "$days days / week" + +private fun formatMileRun(seconds: Int): String { + if (seconds <= 0) return "Not tested" + val min = seconds / 60 + val sec = seconds % 60 + return "%d:%02d".format(min, sec) +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step3Classification.kt b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step3Classification.kt new file mode 100644 index 0000000..c765fe0 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step3Classification.kt @@ -0,0 +1,94 @@ +package com.ironlog.app.ui.screens.onboarding.steps + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.R +import com.ironlog.app.ui.screens.onboarding.GlowCard +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig +import com.ironlog.app.ui.screens.onboarding.SetupBadge + +@Composable +fun Step3Classification( + selectedProgressionStyle: String, + onSelect: (progressionStyle: String, defaultGoalMode: String) -> Unit, + onNext: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.onb_class_header), + color = OnboardingConfig.accentBlue, + fontSize = 20.sp, + fontWeight = FontWeight.Black, + letterSpacing = 3.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.onb_class_subtext), + color = OnboardingConfig.textMuted, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(40.dp)) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 8.dp), + ) { + items(OnboardingConfig.baselineOptions) { option -> + val isSelected = option.progressionStyle == selectedProgressionStyle + GlowCard( + selected = isSelected, + glowColor = option.accent, + onClick = { + onSelect(option.progressionStyle, option.defaultGoalMode) + onNext() + }, + modifier = Modifier.width(184.dp), + ) { + SetupBadge( + code = option.code, + accent = option.accent, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = option.label, + color = if (isSelected) option.accent else OnboardingConfig.textMuted, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.6.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(4.dp)) + Text( + text = option.description, + color = OnboardingConfig.textMuted, + fontSize = 11.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step4Quota.kt b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step4Quota.kt new file mode 100644 index 0000000..4660803 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step4Quota.kt @@ -0,0 +1,131 @@ +package com.ironlog.app.ui.screens.onboarding.steps + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.R +import com.ironlog.app.ui.screens.onboarding.GlowButton +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig + +private val DAY_LABELS = listOf("M", "T", "W", "T", "F", "S", "S") + +@Composable +fun Step4Quota( + selectedDayIndices: Set, + onDayToggle: (index: Int) -> Unit, + weightUnit: String, + onWeightUnitChange: (String) -> Unit, + onNext: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark) + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.onb_quota_header), + color = OnboardingConfig.accentBlue, + fontSize = 20.sp, + fontWeight = FontWeight.Black, + letterSpacing = 3.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(40.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DAY_LABELS.forEachIndexed { index, label -> + val isSelected = index in selectedDayIndices + val bgColor by animateColorAsState( + if (isSelected) OnboardingConfig.accentBlue else Color.Transparent, + tween(200), label = "dot$index" + ) + val textColor by animateColorAsState( + if (isSelected) Color.White else OnboardingConfig.textMuted, + tween(200), label = "dotText$index" + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(bgColor) + .border( + 1.5.dp, + OnboardingConfig.accentBlue.copy(alpha = if (isSelected) 0f else 0.3f), + CircleShape + ) + .clickable { + if (!isSelected || selectedDayIndices.size > 1) onDayToggle(index) + }, + ) { + Text(text = label, color = textColor, fontSize = 13.sp, fontWeight = FontWeight.Bold) + } + } + } + + Spacer(Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.onb_quota_sessions, selectedDayIndices.size), + color = OnboardingConfig.accentGold, + fontSize = 24.sp, + fontWeight = FontWeight.Black, + letterSpacing = 2.sp, + ) + + Spacer(Modifier.height(32.dp)) + + Row( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, OnboardingConfig.accentBlue.copy(alpha = 0.4f), RoundedCornerShape(8.dp)), + ) { + listOf("kg", "lbs").forEach { unit -> + val isActive = unit == weightUnit + val bg by animateColorAsState( + if (isActive) OnboardingConfig.accentBlue else Color.Transparent, tween(200), label = "unit$unit" + ) + val tc by animateColorAsState( + if (isActive) Color.White else OnboardingConfig.textMuted, tween(200), label = "unitText$unit" + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(bg) + .clickable { onWeightUnitChange(unit) } + .padding(horizontal = 24.dp, vertical = 10.dp), + ) { + Text(unit.uppercase(), color = tc, fontWeight = FontWeight.Bold, fontSize = 13.sp) + } + } + } + + Spacer(Modifier.height(48.dp)) + + GlowButton(text = stringResource(R.string.onb_quota_cta), onClick = onNext) + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step5GoalMode.kt b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step5GoalMode.kt new file mode 100644 index 0000000..4bf40f2 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step5GoalMode.kt @@ -0,0 +1,93 @@ +package com.ironlog.app.ui.screens.onboarding.steps + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.R +import com.ironlog.app.ui.screens.onboarding.GlowButton +import com.ironlog.app.ui.screens.onboarding.GlowCard +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig + +private data class GoalOption(val mode: String, val label: String, val subtitle: String) + +private val GOAL_OPTIONS = listOf( + GoalOption("STRENGTH", "STRENGTH", "Max weight, low reps"), + GoalOption("HYPERTROPHY", "HYPERTROPHY", "Size & muscle growth"), + GoalOption("PERFORMANCE", "PERFORMANCE", "Speed & power"), + GoalOption("ENDURANCE", "ENDURANCE", "High volume, stamina"), +) + +@Composable +fun Step5GoalMode( + selectedGoalMode: String, + onSelect: (String) -> Unit, + onNext: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.onb_goal_header), + color = OnboardingConfig.accentBlue, + fontSize = 20.sp, + fontWeight = FontWeight.Black, + letterSpacing = 3.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(32.dp)) + + GOAL_OPTIONS.chunked(2).forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + row.forEach { option -> + val isSelected = option.mode == selectedGoalMode + GlowCard( + selected = isSelected, + glowColor = OnboardingConfig.accentGold, + onClick = { onSelect(option.mode) }, + modifier = Modifier.weight(1f), + ) { + Text( + text = option.label, + color = if (isSelected) OnboardingConfig.accentGold else OnboardingConfig.accentBlue, + fontSize = 12.sp, + fontWeight = FontWeight.Black, + letterSpacing = 2.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(4.dp)) + Text( + text = option.subtitle, + color = OnboardingConfig.textMuted, + fontSize = 11.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + Spacer(Modifier.height(12.dp)) + } + + Spacer(Modifier.height(24.dp)) + + GlowButton(text = stringResource(R.string.onb_goal_cta), onClick = onNext) + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step6AiAbilities.kt b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step6AiAbilities.kt new file mode 100644 index 0000000..f24e8bf --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step6AiAbilities.kt @@ -0,0 +1,407 @@ +package com.ironlog.app.ui.screens.onboarding.steps + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.R +import com.ironlog.app.domain.intelligence.CloudAiEngine +import com.ironlog.app.ui.screens.onboarding.GlowButton +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Step6AiAbilities( + cloudApiKey: String, + cloudModelName: String, + cloudProvider: String, + onApiKeyChange: (String) -> Unit, + onModelChange: (String) -> Unit, + onProviderChange: (provider: String, defaultModel: String) -> Unit, + onNext: () -> Unit, + onSkip: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var keyVisible by remember { mutableStateOf(false) } + var modelExpanded by remember { mutableStateOf(false) } + var showModelSheet by remember { mutableStateOf(false) } + var loadingModels by remember { mutableStateOf(false) } + var verifying by remember { mutableStateOf(false) } + var remoteModels by remember { mutableStateOf>(emptyList()) } + var status by remember { mutableStateOf(null) } + + val provider = OnboardingConfig.providerFor(cloudProvider) + val presetModels = OnboardingConfig.modelsFor(provider) + val modelOptions = remember(provider, remoteModels) { + (remoteModels + presetModels.map { it.modelId }).distinct() + } + val selectedModel = cloudModelName.ifBlank { OnboardingConfig.defaultModelFor(provider) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.onb_ai_header), + color = OnboardingConfig.accentBlue, + fontSize = 28.sp, + fontWeight = FontWeight.Black, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Local coaching stays on by default. Add your own key only if you want cloud plan generation.", + color = OnboardingConfig.textMuted, + fontSize = 14.sp, + lineHeight = 20.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(22.dp)) + Surface( + color = OnboardingConfig.surfaceDark, + shape = RoundedCornerShape(24.dp), + border = BorderStroke(1.dp, OnboardingConfig.cardBorder), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(18.dp)) { + Text("Local coaching is active", color = OnboardingConfig.accentGold, fontSize = 16.sp, fontWeight = FontWeight.ExtraBold) + Spacer(Modifier.height(8.dp)) + Text("Recovery scoring, workout suggestions, and effort tracking stay on-device.", color = OnboardingConfig.textMuted, fontSize = 13.sp, lineHeight = 19.sp) + } + } + + Spacer(Modifier.height(16.dp)) + Surface( + color = OnboardingConfig.surfaceDark, + shape = RoundedCornerShape(24.dp), + border = BorderStroke(1.5.dp, OnboardingConfig.accentBlue.copy(alpha = 0.7f)), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(18.dp)) { + Text("Cloud AI", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Black) + Spacer(Modifier.height(4.dp)) + Text("Gemini 2.0 Flash-Lite or Flash is recommended for free users.", color = OnboardingConfig.textMuted, fontSize = 13.sp, lineHeight = 18.sp) + + Spacer(Modifier.height(18.dp)) + Text("Provider", color = OnboardingConfig.textMuted, fontSize = 13.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OnboardingConfig.AiProvider.entries.forEach { item -> + ProviderChip( + label = item.displayName, + selected = item == provider, + onClick = { + onProviderChange(item.key, OnboardingConfig.defaultModelFor(item)) + remoteModels = emptyList() + status = null + }, + ) + } + } + + Spacer(Modifier.height(14.dp)) + LabeledField(label = "Base URL", value = provider.baseUrl.ifBlank { "Set in Settings after onboarding" }) + + if (provider == OnboardingConfig.AiProvider.GEMINI) { + Spacer(Modifier.height(10.dp)) + Text( + "Get Gemini API key ->", + color = OnboardingConfig.accentBlue, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier.clickable { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(OnboardingConfig.AI_STUDIO_URL))) + }, + ) + } + + Spacer(Modifier.height(14.dp)) + Text("API key", color = OnboardingConfig.textMuted, fontSize = 13.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(6.dp)) + OutlinedTextField( + value = cloudApiKey, + onValueChange = onApiKeyChange, + placeholder = { Text("Paste your key here") }, + visualTransformation = if (keyVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { keyVisible = !keyVisible }) { + Icon( + imageVector = if (keyVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = null, + tint = OnboardingConfig.textMuted, + ) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + colors = onboardingTextFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { + remoteModels = presetModels.map { it.modelId } + showModelSheet = true + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(999.dp), + border = BorderStroke(1.dp, OnboardingConfig.cardBorder), + ) { + Text("Browse ${presetModels.size.coerceAtLeast(modelOptions.size)} Models") + } + OutlinedButton( + onClick = { + scope.launch { + loadingModels = true + status = null + val result = withContext(Dispatchers.IO) { + CloudAiEngine.fetchModels(provider.baseUrl, cloudApiKey.trim(), provider.apiFormat) + } + loadingModels = false + result.onSuccess { + remoteModels = it + showModelSheet = true + status = "Loaded ${it.size} models." + }.onFailure { + status = it.message ?: "Could not load models." + } + } + }, + enabled = cloudApiKey.isNotBlank() && provider.baseUrl.isNotBlank() && !loadingModels, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(999.dp), + border = BorderStroke(1.dp, OnboardingConfig.cardBorder), + ) { + if (loadingModels) CircularProgressIndicator(Modifier.width(18.dp).height(18.dp), strokeWidth = 2.dp) + else Text("Load Models") + } + } + + Spacer(Modifier.height(14.dp)) + Text("Model", color = OnboardingConfig.textMuted, fontSize = 13.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(6.dp)) + ExposedDropdownMenuBox(expanded = modelExpanded, onExpandedChange = { modelExpanded = it }) { + OutlinedTextField( + value = selectedModel, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(modelExpanded) }, + colors = onboardingTextFieldColors(), + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = modelExpanded, onDismissRequest = { modelExpanded = false }) { + modelOptions.forEach { model -> + DropdownMenuItem( + text = { Text(model, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + onClick = { + onModelChange(model) + modelExpanded = false + }, + ) + } + } + } + + status?.let { + Spacer(Modifier.height(12.dp)) + Text(it, color = if (it.startsWith("Loaded") || it.startsWith("Verified")) OnboardingConfig.grantedColor else OnboardingConfig.deniedColor, fontSize = 13.sp) + } + + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { + scope.launch { + verifying = true + status = null + val result = withContext(Dispatchers.IO) { + CloudAiEngine.verify(provider.baseUrl, cloudApiKey.trim(), selectedModel, provider.apiFormat) + } + verifying = false + status = result.fold( + onSuccess = { "Verified. Cloud AI will be ready after onboarding." }, + onFailure = { it.message ?: "Verification failed." }, + ) + } + }, + enabled = cloudApiKey.isNotBlank() && selectedModel.isNotBlank() && !verifying, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(999.dp), + ) { + Text(if (verifying) "Checking..." else "Verify") + } + OutlinedButton( + onClick = { onNext() }, + enabled = true, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(999.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = OnboardingConfig.accentBlue.copy(alpha = 0.2f), + contentColor = Color.White, + ), + ) { + Text(if (cloudApiKey.isBlank()) "Use Local" else "Save") + } + } + } + } + + Spacer(Modifier.height(24.dp)) + GlowButton(text = stringResource(R.string.onb_ai_cta), onClick = onNext) + TextButton(onClick = onSkip, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.onb_ai_skip), color = OnboardingConfig.textMuted, fontSize = 13.sp) + } + Spacer(Modifier.height(24.dp)) + } + + if (showModelSheet) { + ModalBottomSheet( + onDismissRequest = { showModelSheet = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = OnboardingConfig.surfaceDark, + ) { + Text("Choose model", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Black, modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .height(360.dp) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(modelOptions) { model -> + Surface( + color = if (model == selectedModel) OnboardingConfig.accentBlue.copy(alpha = 0.18f) else OnboardingConfig.bgDark.copy(alpha = 0.55f), + shape = RoundedCornerShape(18.dp), + border = BorderStroke(1.dp, if (model == selectedModel) OnboardingConfig.accentBlue else OnboardingConfig.cardBorder), + modifier = Modifier + .fillMaxWidth() + .clickable { + onModelChange(model) + showModelSheet = false + }, + ) { + Text(model, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(16.dp)) + } + } + } + Spacer(Modifier.height(24.dp)) + } + } +} + +@Composable +private fun ProviderChip(label: String, selected: Boolean, onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + shape = RoundedCornerShape(999.dp), + border = BorderStroke(1.dp, if (selected) Color.White else OnboardingConfig.cardBorder), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (selected) Color.White.copy(alpha = 0.12f) else Color.Transparent, + contentColor = if (selected) Color.White else OnboardingConfig.textMuted, + ), + ) { + Text(label, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun LabeledField(label: String, value: String) { + Text(label, color = OnboardingConfig.textMuted, fontSize = 13.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(6.dp)) + Surface( + color = OnboardingConfig.bgDark.copy(alpha = 0.55f), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, OnboardingConfig.cardBorder), + modifier = Modifier.fillMaxWidth(), + ) { + Text(value, color = Color.White, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(14.dp)) + } +} + +@Composable +private fun onboardingTextFieldColors() = OutlinedTextFieldDefaults.colors( + focusedBorderColor = OnboardingConfig.accentBlue, + unfocusedBorderColor = OnboardingConfig.cardBorder, + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedContainerColor = OnboardingConfig.bgDark.copy(alpha = 0.45f), + unfocusedContainerColor = OnboardingConfig.bgDark.copy(alpha = 0.45f), + cursorColor = OnboardingConfig.accentBlue, + focusedPlaceholderColor = OnboardingConfig.textMuted, + unfocusedPlaceholderColor = OnboardingConfig.textMuted, + errorBorderColor = MaterialTheme.colorScheme.error, +) diff --git a/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step7Permissions.kt b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step7Permissions.kt new file mode 100644 index 0000000..2ac6371 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step7Permissions.kt @@ -0,0 +1,244 @@ +package com.ironlog.app.ui.screens.onboarding.steps + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.PermissionController +import com.ironlog.app.R +import com.ironlog.app.data.health.HealthConnectRepository +import com.ironlog.app.ui.screens.onboarding.GlowButton +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig + +@Composable +fun Step7Permissions( + cameraGranted: Boolean, + healthConnectGranted: Boolean, + notificationsGranted: Boolean, + onCameraGranted: (Boolean) -> Unit, + onHealthConnectGranted: (Boolean) -> Unit, + onNotificationsGranted: (Boolean) -> Unit, + onNext: () -> Unit, +) { + val context = LocalContext.current + val healthRepo = remember(context) { HealthConnectRepository(context.applicationContext) } + var healthAvailable by remember { mutableStateOf(false) } + var healthStatus by remember { mutableStateOf(null) } + var healthSdkStatus by remember { mutableStateOf(HealthConnectClient.SDK_UNAVAILABLE) } + + fun openHealthConnectSettings() { + val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + "android.health.connect.action.HEALTH_HOME_SETTINGS" + } else { + "androidx.health.ACTION_HEALTH_CONNECT_SETTINGS" + } + runCatching { + context.startActivity(Intent(action).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + }.onFailure { + context.startActivity(Intent(Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + } + + fun openHealthConnectInstallOrUpdate() { + val hcPackage = "com.google.android.apps.healthdata" + val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$hcPackage")) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val webIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$hcPackage")) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { context.startActivity(marketIntent) } + .onFailure { context.startActivity(webIntent) } + } + + val cameraLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> onCameraGranted(granted) } + + val hcLauncher = rememberLauncherForActivityResult( + PermissionController.createRequestPermissionResultContract() + ) { granted -> + val ok = granted.containsAll(healthRepo.requiredPermissions) + onHealthConnectGranted(ok) + healthStatus = if (ok) "Health Connect is connected." else "Health Connect was not granted. You can enable it later in Settings." + } + + val notifLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> onNotificationsGranted(granted) } + + LaunchedEffect(Unit) { + healthSdkStatus = HealthConnectClient.getSdkStatus(context.applicationContext) + healthAvailable = healthRepo.isAvailable() + if (!healthAvailable) { + healthStatus = when (healthSdkStatus) { + HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> + "Health Connect needs install/update. Tap Allow to open Play Store." + else -> + "Health Connect is not available on this device." + } + } else if (healthRepo.hasAllPermissions()) { + onHealthConnectGranted(true) + healthStatus = "Health Connect is already connected." + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(72.dp)) + Text( + text = stringResource(R.string.onb_perm_header), + color = OnboardingConfig.accentBlue, + fontSize = 26.sp, + fontWeight = FontWeight.Black, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.onb_perm_subtext), + color = OnboardingConfig.textMuted, + fontSize = 14.sp, + lineHeight = 20.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(28.dp)) + + PermissionCard( + title = stringResource(R.string.onb_perm_camera_title), + description = stringResource(R.string.onb_perm_camera_desc), + granted = cameraGranted, + onGrant = { cameraLauncher.launch(Manifest.permission.CAMERA) }, + ) + Spacer(Modifier.height(12.dp)) + PermissionCard( + title = stringResource(R.string.onb_perm_health_title), + description = stringResource(R.string.onb_perm_health_desc), + granted = healthConnectGranted, + status = healthStatus, + onGrant = { + if (!healthAvailable) { + healthStatus = if (healthSdkStatus == HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED) { + "Opening Health Connect in Play Store..." + } else { + "Opening Health Connect settings..." + } + if (healthSdkStatus == HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED) { + openHealthConnectInstallOrUpdate() + } else { + openHealthConnectSettings() + } + } else { + hcLauncher.launch(healthRepo.requiredPermissions) + } + }, + ) + Spacer(Modifier.height(12.dp)) + PermissionCard( + title = stringResource(R.string.onb_perm_notif_title), + description = stringResource(R.string.onb_perm_notif_desc), + granted = notificationsGranted, + onGrant = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notifLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + onNotificationsGranted(true) + } + }, + ) + + Spacer(Modifier.weight(1f)) + GlowButton(text = stringResource(R.string.onb_perm_cta), onClick = onNext) + Spacer(Modifier.height(32.dp)) + } +} + +@Composable +private fun PermissionCard( + title: String, + description: String, + granted: Boolean, + status: String? = null, + onGrant: () -> Unit, +) { + val borderColor by animateColorAsState( + targetValue = if (granted) OnboardingConfig.grantedColor else OnboardingConfig.cardBorder, + animationSpec = tween(300), + label = "permBorder", + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(22.dp)) + .background(OnboardingConfig.surfaceDark) + .border(1.5.dp, borderColor, RoundedCornerShape(22.dp)) + .padding(18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(title, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.ExtraBold) + Spacer(Modifier.height(4.dp)) + Text(description, color = OnboardingConfig.textMuted, fontSize = 13.sp, lineHeight = 18.sp) + if (!status.isNullOrBlank()) { + Spacer(Modifier.height(6.dp)) + Text(status, color = if (granted) OnboardingConfig.grantedColor else OnboardingConfig.accentGold, fontSize = 12.sp) + } + } + Spacer(Modifier.width(12.dp)) + OutlinedButton( + onClick = onGrant, + enabled = !granted, + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (granted) OnboardingConfig.grantedColor.copy(alpha = 0.18f) else Color.Transparent, + contentColor = if (granted) Color.White else OnboardingConfig.accentBlue, + ), + border = BorderStroke(1.dp, if (granted) OnboardingConfig.grantedColor else OnboardingConfig.accentBlue), + shape = RoundedCornerShape(999.dp), + ) { + Text( + text = if (granted) stringResource(R.string.onb_perm_granted) else stringResource(R.string.onb_perm_grant), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ) + } + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step8Complete.kt b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step8Complete.kt new file mode 100644 index 0000000..98ac352 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step8Complete.kt @@ -0,0 +1,181 @@ +package com.ironlog.app.ui.screens.onboarding.steps + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.R +import com.ironlog.app.ui.screens.onboarding.GlowButton +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig +import com.ironlog.app.ui.screens.onboarding.ParticleField +import com.ironlog.app.ui.screens.onboarding.SetupBadge +import kotlin.math.max +import kotlinx.coroutines.delay + +@Composable +fun Step8Complete( + userName: String, + weeklyGoalDays: Int, + weightUnit: String, + goalMode: String, + progressionStyle: String, + intelligenceMode: String, + healthConnectGranted: Boolean, + notificationsGranted: Boolean, + qualifiedBadge: String, + onStartTraining: () -> Unit, +) { + var showContent by remember { mutableStateOf(false) } + var revealDone by remember { mutableStateOf(false) } + var slotBadge by remember { mutableStateOf("Uncalibrated") } + val badgeOrder = remember { + listOf("Uncalibrated", "Graphite", "Iron", "Steel", "Titanium", "Obsidian", "Iridium", "Aether", "Apex") + } + LaunchedEffect(Unit) { + delay(OnboardingConfig.COMPLETION_REVEAL_DELAY_MS) + showContent = true + } + LaunchedEffect(showContent, qualifiedBadge) { + if (!showContent) return@LaunchedEffect + val targetIndex = badgeOrder.indexOf(qualifiedBadge).coerceAtLeast(0) + val totalTicks = max(16, targetIndex + 12) + for (i in 0 until totalTicks) { + slotBadge = badgeOrder[i % badgeOrder.size] + delay((55L + (i * 5L)).coerceAtMost(170L)) + } + slotBadge = qualifiedBadge + revealDone = true + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark), + ) { + ParticleField() + + if (showContent) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp, vertical = 28.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + SetupBadge( + code = slotBadge.take(2).uppercase(), + accent = OnboardingConfig.accentBlue, + ) + + Spacer(Modifier.height(10.dp)) + Text( + text = if (revealDone) "Qualified badge: $slotBadge" else "Evaluating your badge...", + color = OnboardingConfig.textMuted, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(20.dp)) + + val displayName = userName.ifBlank { "Athlete" } + Text( + text = displayName, + color = OnboardingConfig.accentBlue, + fontSize = 30.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 0.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.onb_arise_reg_complete), + color = OnboardingConfig.textMuted, + fontSize = 18.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 0.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.onb_arise_line1), + color = OnboardingConfig.textMuted, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.onb_arise_line2), + color = OnboardingConfig.textMuted, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(22.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .background(OnboardingConfig.surfaceDark, RoundedCornerShape(16.dp)) + .border(1.dp, OnboardingConfig.cardBorder, RoundedCornerShape(16.dp)) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SummaryLine("Goal", goalMode.toDisplayLabel()) + SummaryLine("Progression", progressionStyle.toDisplayLabel()) + SummaryLine("Weekly target", "$weeklyGoalDays days / week") + SummaryLine("Weight unit", weightUnit.uppercase()) + SummaryLine("AI mode", intelligenceMode.toDisplayLabel()) + SummaryLine( + "Permissions", + buildList { + add(if (healthConnectGranted) "Health connected" else "Health skipped") + add(if (notificationsGranted) "Notifications on" else "Notifications skipped") + }.joinToString(" / "), + ) + } + + Spacer(Modifier.height(34.dp)) + + GlowButton( + text = stringResource(R.string.onb_arise_cta), + onClick = onStartTraining, + ) + } + } + } +} + +@Composable +private fun SummaryLine(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, color = OnboardingConfig.textMuted, fontSize = 12.sp) + Text(value, color = OnboardingConfig.accentBlue, fontSize = 13.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.End) + } +} + +private fun String.toDisplayLabel(): String = + lowercase() + .split("_") + .filter { it.isNotBlank() } + .joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } } diff --git a/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step9ProgramSetup.kt b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step9ProgramSetup.kt new file mode 100644 index 0000000..d7dd017 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step9ProgramSetup.kt @@ -0,0 +1,137 @@ +package com.ironlog.app.ui.screens.onboarding.steps + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.data.seed.PROGRAM_TEMPLATES +import com.ironlog.app.data.seed.ProgramTemplate +import com.ironlog.app.ui.screens.onboarding.GlowButton +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig + +@Composable +fun Step9ProgramSetup( + onSkip: () -> Unit, + onApplyTemplate: (ProgramTemplate) -> Unit, +) { + var selected by remember { mutableStateOf(null) } + val templates = remember { PROGRAM_TEMPLATES.take(8) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark) + .padding(horizontal = 24.dp, vertical = 20.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Pick a starter plan", + color = OnboardingConfig.accentBlue, + fontSize = 34.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 0.sp, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Select one preloaded plan now, or skip and choose later in Plans.", + color = OnboardingConfig.textMuted, + fontSize = 15.sp, + ) + Spacer(Modifier.height(16.dp)) + + templates.forEach { plan -> + val isSelected = selected?.id == plan.id + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .border( + width = 1.5.dp, + color = if (isSelected) OnboardingConfig.accentBlue else OnboardingConfig.cardBorder, + shape = RoundedCornerShape(16.dp), + ) + .background( + color = if (isSelected) OnboardingConfig.surfaceDark.copy(alpha = 0.95f) else OnboardingConfig.surfaceDark, + shape = RoundedCornerShape(16.dp), + ) + .clickable { selected = plan } + .padding(14.dp), + ) { + Text( + text = plan.name, + color = OnboardingConfig.accentBlue, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = plan.description, + color = OnboardingConfig.textMuted, + fontSize = 13.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PlanChip("${plan.days.size} days/week") + PlanChip(plan.category) + PlanChip("${plan.durationWeeks} weeks") + } + } + } + + Spacer(Modifier.height(14.dp)) + GlowButton( + text = if (selected != null) "Use selected plan" else "Skip for now", + onClick = { + val choice = selected + if (choice != null) onApplyTemplate(choice) else onSkip() + }, + ) + Spacer(Modifier.height(10.dp)) + Text( + text = "You can import or switch plans anytime in the Plans tab.", + color = OnboardingConfig.textMuted, + fontSize = 12.sp, + ) + Spacer(Modifier.height(20.dp)) + } +} + +@Composable +private fun PlanChip(text: String) { + Text( + text = text, + color = OnboardingConfig.textMuted, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .border(1.dp, OnboardingConfig.cardBorder, RoundedCornerShape(999.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/plans/AIPlanScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/plans/AIPlanScreen.kt new file mode 100644 index 0000000..7dd39c9 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/plans/AIPlanScreen.kt @@ -0,0 +1,901 @@ +package com.ironlog.app.ui.screens.plans + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Warning +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import com.ironlog.app.data.model.CreateExerciseInput +import com.ironlog.app.data.model.FullPlanDay +import com.ironlog.app.data.model.FullPlanObject +import com.ironlog.app.data.model.LegacyExerciseShape +import com.ironlog.app.data.model.PlanExerciseInput +import com.ironlog.app.data.repository.ExerciseRepository +import com.ironlog.app.data.repository.PlanRepository +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.domain.ai.ExerciseResolutionEngine +import com.ironlog.app.domain.ai.ResolutionStatus +import com.ironlog.app.domain.ai.SmartPlanStructuringEngine +import com.ironlog.app.domain.intelligence.CloudAiEngine +import com.ironlog.app.domain.intelligence.CloudAiKeyStore +import com.ironlog.app.services.ShareService +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.viewmodel.AppDataViewModel +import com.ironlog.app.util.normalizeExerciseNameKey +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.Date + +private enum class Step { INTRO, QUIZ, PROMPT, LIBRARY, PASTE, PREVIEW, CLOUD_GENERATING } + +private data class AlertData( + val title: String, + val message: String, + val confirmLabel: String = "OK", + val onConfirm: (() -> Unit)? = null +) +private data class NeedsReviewRow( + val suggestedId: String?, + val suggestedText: String, + val candidates: List>, +) + +private val MUSCLE_OPTIONS = listOf("Chest", "Back", "Shoulders", "Biceps", "Triceps", "Quads", "Hamstrings", "Glutes", "Calves", "Core", "Forearms", "Traps", "Cardio") +private val EQUIP_OPTIONS = listOf("Barbell", "Dumbbell", "Cable", "Machine", "Bodyweight", "Band", "Kettlebell", "Other") +private val GOAL_OPTIONS = listOf("Hypertrophy", "Strength", "Fat Loss", "General Fitness", "Custom") +private val DURATION_OPTIONS = listOf("45", "60", "75", "90", "120", "150") + +@Composable +fun AIPlanScreen(planRepo: PlanRepository = PlanRepository(), onBack: () -> Unit = {}) { + val c = useTheme() + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + val settingsRepo = remember { SettingsRepository() } + val exerciseRepo = remember { ExerciseRepository(context) } + + val appVm: AppDataViewModel = viewModel() + val appState by appVm.state.collectAsState() + val cloudSettings = appState.settings + val cloudApiKey = remember(cloudSettings.cloudAiProviderPreset) { + CloudAiKeyStore.load(context, cloudSettings.cloudAiProviderPreset) + } + val cloudConfigured = cloudApiKey.isNotBlank() + && cloudSettings.cloudAiBaseUrl.isNotBlank() + && cloudSettings.cloudAiModelName.isNotBlank() + + var step by remember { mutableStateOf(Step.INTRO) } + var equipment by remember { mutableStateOf>(emptyList()) } + var haptic by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + haptic = settingsRepo.getBoolean("hapticFeedback", true) + // Just use a default equipment list if profiles are missing for now to keep it simple, + // or fetch from profiles if available. + val gpStr = settingsRepo.getString("ironlog_gym_profiles") + val actId = settingsRepo.getString("ironlog_active_gym_profile_id") + if (gpStr != null && actId != null) { + try { + val arr = JSONArray(gpStr) + for (i in 0 until arr.length()) { + val p = arr.getJSONObject(i) + if (p.optString("id") == actId) { + val eqArr = p.optJSONArray("equipment") ?: JSONArray() + val eqList = mutableListOf() + for (j in 0 until eqArr.length()) eqList += eqArr.getString(j) + equipment = eqList + break + } + } + } catch (e: Exception) {} + } + } + } + + var daysPerWeek by remember { mutableStateOf(4) } + var goalOption by remember { mutableStateOf("Hypertrophy") } + var customGoal by remember { mutableStateOf("") } + var sessionDuration by remember { mutableStateOf("60") } + var cardioEverySession by remember { mutableStateOf(false) } + + var editablePrompt by remember { mutableStateOf("") } + var pastedJson by remember { mutableStateOf("") } + + var parsedPlan by remember { mutableStateOf(null) } + var parseWarnings by remember { mutableStateOf>(emptyList()) } + var parseNeedsReview by remember { mutableStateOf>(emptyMap()) } + var manualReviewResolution by remember { mutableStateOf>(emptyMap()) } // exerciseName -> exerciseId + var pickingReviewName by remember { mutableStateOf(null) } + var smartNotes by remember { mutableStateOf>(emptyList()) } + var missingConfigs by remember { mutableStateOf>>(emptyMap()) } // map of Name -> (Muscle, Equipment) + var autoAddedCount by remember { mutableStateOf(0) } + + var loading by remember { mutableStateOf(false) } + var alertConfig by remember { mutableStateOf(null) } + var promptShared by remember { mutableStateOf(false) } + var libraryShared by remember { mutableStateOf(false) } + + fun buildExerciseCatalogMarkdown(exercises: List): String { + val header = """ +# Ironlog Exercise Catalog (Compact) + +Use this catalog as the source of truth for exercise naming and metadata. +Format: `Exercise Name | Primary Muscle | Equipment | Category | Tracking Type | Movement Pattern` +""".trimIndent() + val lines = exercises + .sortedBy { it.name.lowercase() } + .map { ex -> + val primary = ex.primaryMuscle ?: ex.primaryMuscles.firstOrNull() ?: "Unknown" + "${ex.name} | $primary | ${ex.equipment} | ${ex.category} | ${ex.trackingType} | ${ex.movementPattern}" + } + return buildString { + appendLine(header) + appendLine() + appendLine("```text") + lines.forEach { appendLine(it) } + appendLine("```") + } + } + + fun buildPrompt(exerciseCatalogMarkdown: String) { + val g = if (goalOption == "Custom") customGoal.trim().ifEmpty { "general fitness" } else goalOption + val eqList = if (equipment.isNotEmpty()) equipment.joinToString(", ") else "Barbell, Dumbbell, Cable, Machine" + val cardioNote = if (cardioEverySession) "Include 10-15 min low-intensity cardio only if it fits the plan. Do not mark it as a warmup." else "No dedicated cardio needed." + editablePrompt = """ +You are a professional strength & conditioning coach. Create a ${daysPerWeek}-day-per-week training plan optimised for ${g}. Each session should be around ${sessionDuration} minutes. ${cardioNote} + +AVAILABLE EQUIPMENT: ${eqList} + +OUTPUT RULES - read carefully: +1. Respond ONLY with a single JSON object. No markdown code fences, no prose, no comments. +2. The JSON must match this exact schema: + +{ + "version": 1, + "type": "ironlog_plan", + "exportedAt": "", + "plan": { + "name": "", + "days": [ + { + "name": "", + "color": "", + "exercises": [ + { + "exerciseName": "", + "primaryMuscle": "", + "secondaryMuscles": [""], + "equipment": "", + "category": "", + "trackingType": "", + "movementPattern": "", + "difficulty": "", + "isBodyweight": , + "sets": , + "reps": "", + "restSeconds": , + "supersetGroup": null, + "isWarmup": false, + "notes": "" + } + ] + } + ] + } +} + +3. Prefer exercise names from the compact exercise catalog provided below. If no suitable exercise exists, invent a clear descriptive name and MUST include primaryMuscle, secondaryMuscles, equipment, category, trackingType, movementPattern, difficulty, and isBodyweight so Ironlog can add it to the exercise library. +4. Include 4-7 exercises per day. Do not add warmup exercises or warmup sets. Every exercise must use "isWarmup": false. +5. Use these day colors: Push #FF4500, Pull #0080FF, Legs #00C170, Upper #A020F0, Full Body #FF8C00, other days use #888888. +6. restSeconds: compound lifts 120-180s, isolation 60-90s. +7. Valid primaryMuscle values: Chest, Back, Shoulders, Biceps, Triceps, Quads, Hamstrings, Glutes, Calves, Core, Forearms, Traps, Cardio. +8. Valid equipment values: Barbell, Dumbbell, Cable, Machine, Bodyweight, Band, Kettlebell, Other. +9. For ${g} training: prefer movementPatterns that match the goal (e.g. hinge/squat/push/pull compounds for Strength, higher isolation volume for Hypertrophy, mixed patterns for General Fitness). + +Compact exercise catalog: +$exerciseCatalogMarkdown + +Generate the plan now. Output ONLY the JSON object. +""".trimIndent() + step = Step.PROMPT + } + + suspend fun doParsePlan(jsonString: String, exercises: List): Pair { + val data = try { + // 1. Strip markdown code fences anywhere in the string + var cleaned = jsonString.trim() + .replace(Regex("```json\\s*", RegexOption.IGNORE_CASE), "") + .replace(Regex("```\\s*"), "") + .trim() + // 2. Try direct parse first + runCatching { JSONObject(cleaned) }.getOrElse { + // 3. Fall back: extract the outermost {...} block in case the model added prose + val start = cleaned.indexOf('{') + val end = cleaned.lastIndexOf('}') + if (start != -1 && end > start) { + JSONObject(cleaned.substring(start, end + 1)) + } else { + throw it + } + } + } catch (e: Exception) { + return false to "Could not parse JSON. Make sure you pasted the full AI response." + } + if (!data.optString("type").equals("ironlog_plan", ignoreCase = true) || data.optJSONObject("plan")?.optString("name").isNullOrBlank() || data.optJSONObject("plan")?.optJSONArray("days") == null) { + return false to "Invalid plan format. The AI response must follow the IronLog schema." + } + + val warnings = mutableListOf() + val needsReview = mutableMapOf() + val newExs = mutableListOf() + + val p = data.getJSONObject("plan") + val daysArr = p.getJSONArray("days") + val parsedDays = mutableListOf() + + for (i in 0 until daysArr.length()) { + val d = daysArr.getJSONObject(i) + val exArr = d.optJSONArray("exercises") ?: JSONArray() + val parsedExs = mutableListOf() + for (j in 0 until exArr.length()) { + val ex = exArr.getJSONObject(j) + val exName = ex.optString("exerciseName", "Unknown") + val resolution = ExerciseResolutionEngine.resolve(exName, exercises) + val exactMatch = findExactExercise(exName, exercises) + val matched = exactMatch ?: if (resolution.status == ResolutionStatus.MATCHED) resolution.matched else null + when { + matched != null -> {} + resolution.status == ResolutionStatus.NEEDS_REVIEW -> { + val custom = customExerciseFromAiMetadata(ex, exName) + if (custom != null && !hasExactExerciseName(exName, exercises)) { + newExs.add(custom) + } else { + val suggested = resolution.matched?.name ?: "Unknown" + needsReview[exName] = NeedsReviewRow( + suggestedId = resolution.matched?.id, + suggestedText = "Suggested: $suggested (${resolution.confidence}%)", + candidates = resolution.topCandidates.map { (exRow, score) -> exRow.id to "${exRow.name} ($score%)" }, + ) + } + } + resolution.status == ResolutionStatus.UNRESOLVED -> { + val custom = customExerciseFromAiMetadata(ex, exName) + if (custom != null) { + newExs.add(custom) + } else { + warnings.add(exName) + } + } + else -> {} + } + parsedExs.add(PlanExerciseInput( + exerciseId = matched?.id, + name = exName, + sets = ex.optInt("sets", 3), + reps = ex.optString("reps", "10"), + restSeconds = ex.optInt("restSeconds", 90), + supersetGroup = if (ex.isNull("supersetGroup")) null else ex.optString("supersetGroup").takeIf { it.isNotBlank() }, + isWarmup = false, + notes = ex.optString("notes", "") + )) + } + parsedDays.add(FullPlanDay( + name = d.optString("name", "Day"), + color = d.optString("color", "#888888"), + exercises = parsedExs + )) + } + + if (newExs.isNotEmpty()) { + var added = 0 + for (nx in newExs.distinctBy { normalizeExerciseNameKey(it.name.orEmpty()) }) { + try { exerciseRepo.createCustomExercise(nx); added++ } catch (e: Exception) {} + } + autoAddedCount = added + // Re-validate to pick up the newly added ones + val refreshed = exerciseRepo.getExercisesSnapshot() + val reWarnings = mutableListOf() + val reNeedsReview = mutableMapOf() + val reParsedDays = mutableListOf() + for (i in 0 until daysArr.length()) { + val d = daysArr.getJSONObject(i) + val exArr = d.optJSONArray("exercises") ?: JSONArray() + val parsedExs = mutableListOf() + for (j in 0 until exArr.length()) { + val ex = exArr.getJSONObject(j) + val exName = ex.optString("exerciseName", "Unknown") + val resolution = ExerciseResolutionEngine.resolve(exName, refreshed) + val exactMatch = findExactExercise(exName, refreshed) + val matched = exactMatch ?: if (resolution.status == ResolutionStatus.MATCHED) resolution.matched else null + when { + matched != null -> {} + resolution.status == ResolutionStatus.NEEDS_REVIEW -> { + val custom = customExerciseFromAiMetadata(ex, exName) + if (custom != null && !hasExactExerciseName(exName, refreshed)) { + try { exerciseRepo.createCustomExercise(custom) } catch (e: Exception) {} + } else if (!reNeedsReview.containsKey(exName)) { + val suggested = resolution.matched?.name ?: "Unknown" + reNeedsReview[exName] = NeedsReviewRow( + suggestedId = resolution.matched?.id, + suggestedText = "Suggested: $suggested (${resolution.confidence}%)", + candidates = resolution.topCandidates.map { (exRow, score) -> exRow.id to "${exRow.name} ($score%)" }, + ) + } + } + resolution.status == ResolutionStatus.UNRESOLVED -> { + if (!reWarnings.contains(exName)) reWarnings.add(exName) + } + } + parsedExs.add(PlanExerciseInput( + exerciseId = matched?.id, + name = exName, + sets = ex.optInt("sets", 3), + reps = ex.optString("reps", "10"), + restSeconds = ex.optInt("restSeconds", 90), + supersetGroup = if (ex.isNull("supersetGroup")) null else ex.optString("supersetGroup").takeIf { it.isNotBlank() }, + isWarmup = false, + notes = ex.optString("notes", "") + )) + } + reParsedDays.add(FullPlanDay( + name = d.optString("name", "Day"), + color = d.optString("color", "#888888"), + exercises = parsedExs + )) + } + val finalLibrary = exerciseRepo.getExercisesSnapshot() + val structured = SmartPlanStructuringEngine.structure( + plan = FullPlanObject(name = p.optString("name"), goal = "", description = "", days = reParsedDays), + library = finalLibrary, + ) + parsedPlan = structured.plan + parseWarnings = reWarnings + parseNeedsReview = reNeedsReview + manualReviewResolution = emptyMap() + smartNotes = structured.notes + missingConfigs = (reWarnings + reNeedsReview.keys).distinct().associateWith { "" to "Other" } + return true to null + } + + val structured = SmartPlanStructuringEngine.structure( + plan = FullPlanObject(name = p.optString("name"), goal = "", description = "", days = parsedDays), + library = exercises, + ) + parsedPlan = structured.plan + parseWarnings = warnings + parseNeedsReview = needsReview + manualReviewResolution = emptyMap() + smartNotes = structured.notes + missingConfigs = (warnings + needsReview.keys).distinct().associateWith { "" to "Other" } + return true to null + } + + LazyColumn( + modifier = Modifier.fillMaxSize().background(c.bg).statusBarsPadding().padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 16.dp, bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { ScreenHeader(title = "AI PLAN CREATOR", onBack = onBack) } + if (step == Step.INTRO) { + item { + Column(Modifier.fillMaxWidth().background(c.card, RoundedCornerShape(18.dp)).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text("CREATE PLAN WITH AI", color = c.text, fontWeight = FontWeight.Black, fontSize = IronLogType.title.fontSize.sp, letterSpacing = 1.5.sp) + Text("Use any AI assistant (Claude, ChatGPT, Gemini) to build a custom training plan - then import it directly into IronLog.", color = c.subtext, fontSize = IronLogType.body.fontSize.sp) + listOf("Answer a few questions about your goals", "Copy the tailored prompt to your AI app", "Share the exercise library file", "Paste the AI response back & import").forEachIndexed { i, txt -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(Modifier.size(26.dp).background(c.accentSoft, RoundedCornerShape(13.dp)).border(1.dp, c.accentBorder, RoundedCornerShape(13.dp)), contentAlignment = Alignment.Center) { + Text("${i + 1}", color = c.accent, fontWeight = FontWeight.Black, fontSize = IronLogType.meta.fontSize.sp) + } + Text(txt, color = c.text, fontSize = IronLogType.body.fontSize.sp, modifier = Modifier.weight(1f)) + } + } + Button(onClick = { step = Step.QUIZ }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { Text("GET STARTED") } + } + } + } + + if (step == Step.QUIZ) { + item { + Column(Modifier.fillMaxWidth().background(c.card, RoundedCornerShape(18.dp)).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Surface(color = c.accentSoft, shape = RoundedCornerShape(999.dp)) { Text("STEP 1 OF 4", color = c.accent, fontWeight = FontWeight.Black, fontSize = IronLogType.eyebrow.fontSize.sp, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)) } + Text("PLAN DETAILS", color = c.text, fontWeight = FontWeight.Black, fontSize = IronLogType.title.fontSize.sp, letterSpacing = 1.5.sp) + + Text("DAYS PER WEEK", color = c.muted, fontWeight = FontWeight.Black, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 1.5.sp) + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedButton(onClick = { daysPerWeek = maxOf(1, daysPerWeek - 1) }) { Text("-", color = c.text) } + Text(daysPerWeek.toString(), color = c.text, fontWeight = FontWeight.Black, fontSize = IronLogType.title.fontSize.sp, modifier = Modifier.padding(horizontal = 20.dp)) + OutlinedButton(onClick = { daysPerWeek = minOf(7, daysPerWeek + 1) }) { Text("+", color = c.text) } + } + + Text("GOAL", color = c.muted, fontWeight = FontWeight.Black, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 1.5.sp) + @OptIn(ExperimentalLayoutApi::class) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + GOAL_OPTIONS.forEach { opt -> + FilterChip(selected = goalOption == opt, onClick = { goalOption = opt }, label = { Text(opt) }) + } + } + if (goalOption == "Custom") { + OutlinedTextField(value = customGoal, onValueChange = { customGoal = it }, placeholder = { Text("Describe your goal...") }, modifier = Modifier.fillMaxWidth()) + } + + Text("SESSION LENGTH", color = c.muted, fontWeight = FontWeight.Black, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 1.5.sp) + @OptIn(ExperimentalLayoutApi::class) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + DURATION_OPTIONS.forEach { opt -> + FilterChip(selected = sessionDuration == opt, onClick = { sessionDuration = opt }, label = { Text("$opt min") }) + } + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Column { + Text("CARDIO EVERY SESSION", color = c.muted, fontWeight = FontWeight.Black, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 1.5.sp) + Text("Include optional short cardio", color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + } + Switch(checked = cardioEverySession, onCheckedChange = { cardioEverySession = it }) + } + + Button(onClick = { + scope.launch(Dispatchers.IO) { + val catalogMarkdown = exerciseRepo.getCompactCatalogMarkdown() + withContext(Dispatchers.Main) { + buildPrompt(catalogMarkdown) + } + } + }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { Text("BUILD PROMPT ->") } + if (cloudConfigured) { + Button( + onClick = { + step = Step.CLOUD_GENERATING + scope.launch { + val allExercises = withContext(Dispatchers.IO) { exerciseRepo.getExercisesSnapshot() } + val exerciseCatalogMarkdown = withContext(Dispatchers.IO) { exerciseRepo.getCompactCatalogMarkdown() } + val json = CloudAiEngine.generatePlanJson( + baseUrl = cloudSettings.cloudAiBaseUrl, + apiKey = cloudApiKey, + modelName = cloudSettings.cloudAiModelName, + apiFormat = cloudSettings.cloudAiApiFormat, + daysPerWeek = daysPerWeek, + goalMode = if (goalOption == "Custom") customGoal else goalOption, + equipment = equipment, + sessionDurationMin = sessionDuration.toIntOrNull() ?: 60, + cardioEverySession = cardioEverySession, + exerciseCatalogMarkdown = exerciseCatalogMarkdown, + ) + if (json.isBlank()) { + alertConfig = AlertData("Generation Failed", "Cloud AI returned an empty response. Check your settings and try again.") + step = Step.QUIZ + return@launch + } + val (ok, error) = withContext(Dispatchers.IO) { doParsePlan(json, allExercises) } + if (ok && parsedPlan != null) { + step = Step.PREVIEW + } else { + alertConfig = AlertData("Generation Failed", error ?: "The AI response could not be parsed. Please try again.") + step = Step.QUIZ + } + } + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = c.accent), + ) { + Text("CREATE WITH CLOUD AI ?", color = c.textOnAccent) + } + } + } + } + } + + if (step == Step.CLOUD_GENERATING) { + item { + Column( + Modifier.fillMaxWidth().background(c.card, RoundedCornerShape(18.dp)).padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator(color = c.accent, modifier = Modifier.size(36.dp)) + Text("Generating your plan…", color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.section.fontSize.sp) + Text( + "${cloudSettings.cloudAiDisplayName.ifBlank { "Cloud AI" }} is creating a personalised ${daysPerWeek}-day plan for you.", + color = c.subtext, + fontSize = IronLogType.body.fontSize.sp, + textAlign = TextAlign.Center, + ) + } + } + } + + if (step == Step.PROMPT) { + item { + Column(Modifier.fillMaxWidth().background(c.card, RoundedCornerShape(18.dp)).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Surface(color = c.accentSoft, shape = RoundedCornerShape(999.dp)) { Text("STEP 2 OF 4", color = c.accent, fontWeight = FontWeight.Black, fontSize = IronLogType.eyebrow.fontSize.sp, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)) } + Text("REVIEW PROMPT", color = c.text, fontWeight = FontWeight.Black, fontSize = IronLogType.title.fontSize.sp, letterSpacing = 1.5.sp) + OutlinedTextField( + value = editablePrompt, + onValueChange = { editablePrompt = it }, + modifier = Modifier.fillMaxWidth().height(240.dp), + textStyle = androidx.compose.ui.text.TextStyle(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, fontSize = IronLogType.meta.fontSize.sp, color = c.text) + ) + Button(onClick = { + clipboard.setText(AnnotatedString(editablePrompt)) + ShareService.sharePlainText(context, "IronLog AI Plan Prompt", editablePrompt) + promptShared = true + }, modifier = Modifier.fillMaxWidth()) { Text("SHARE PROMPT") } + + if (promptShared) { + Text("Prompt shared - proceed to step 3", color = c.accent, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Bold) + } + + OutlinedButton(onClick = { step = Step.LIBRARY }, modifier = Modifier.fillMaxWidth()) { Text("ALREADY COPIED -> NEXT STEP") } + } + } + } + + if (step == Step.LIBRARY) { + item { + Column(Modifier.fillMaxWidth().background(c.card, RoundedCornerShape(18.dp)).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Surface(color = c.accentSoft, shape = RoundedCornerShape(999.dp)) { Text("STEP 3 OF 4", color = c.accent, fontWeight = FontWeight.Black, fontSize = IronLogType.eyebrow.fontSize.sp, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)) } + Text("SHARE EXERCISE CATALOG", color = c.text, fontWeight = FontWeight.Black, fontSize = IronLogType.title.fontSize.sp, letterSpacing = 1.5.sp) + Text("Share your exercise catalog as a compact Markdown file. The AI should use this list for names and metadata.", color = c.subtext, fontSize = IronLogType.body.fontSize.sp) + + if (equipment.isNotEmpty()) { + Text("Filtered to your gym: ${equipment.joinToString(", ")}", color = c.accent, fontSize = IronLogType.meta.fontSize.sp, modifier = Modifier.background(c.accentSoft, RoundedCornerShape(8.dp)).padding(12.dp)) + } + + Button(onClick = { + scope.launch(Dispatchers.IO) { + loading = true + try { + val allExercises = exerciseRepo.getExercisesSnapshot() + val filtered = if (equipment.isNotEmpty()) { + allExercises.filter { ex -> ex.equipment == "Other" || equipment.any { e -> ex.equipment.lowercase().contains(e.lowercase()) } } + } else allExercises + + val payload = buildExerciseCatalogMarkdown(filtered) + + val cacheDir = File(context.cacheDir, "exports").apply { mkdirs() } + val file = File(cacheDir, "ironlog_exercise_catalog_compact.md") + file.writeText(payload) + + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + withContext(Dispatchers.Main) { + ShareService.shareFile(context, "Share Exercise Catalog", uri, "text/markdown") + libraryShared = true + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { alertConfig = AlertData("Share failed", e.message ?: "Unknown error") } + } finally { + loading = false + } + } + }, modifier = Modifier.fillMaxWidth(), enabled = !loading) { Text(if (loading) "PREPARING..." else "SHARE CATALOG FILE") } + + if (libraryShared) { + Text("Library shared - generate your plan, then come back", color = c.accent, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Bold) + } + + OutlinedButton(onClick = { step = Step.PASTE }, modifier = Modifier.fillMaxWidth()) { Text("ALREADY DONE -> PASTE RESPONSE") } + } + } + } + + if (step == Step.PASTE) { + item { + Column(Modifier.fillMaxWidth().background(c.card, RoundedCornerShape(18.dp)).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Surface(color = c.accentSoft, shape = RoundedCornerShape(999.dp)) { Text("STEP 4 OF 4", color = c.accent, fontWeight = FontWeight.Black, fontSize = IronLogType.eyebrow.fontSize.sp, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)) } + Text("PASTE AI RESPONSE", color = c.text, fontWeight = FontWeight.Black, fontSize = IronLogType.title.fontSize.sp, letterSpacing = 1.5.sp) + + OutlinedTextField( + value = pastedJson, + onValueChange = { pastedJson = it }, + modifier = Modifier.fillMaxWidth().height(200.dp), + placeholder = { Text("{\n \"version\": 1,\n \"type\": \"ironlog_plan\",\n ...\n}") }, + textStyle = androidx.compose.ui.text.TextStyle(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, fontSize = IronLogType.meta.fontSize.sp, color = c.text) + ) + + Button(onClick = { + if (pastedJson.isBlank()) { + alertConfig = AlertData("Nothing pasted", "Paste the AI-generated JSON first.") + return@Button + } + scope.launch(Dispatchers.IO) { + loading = true + try { + val exercises = exerciseRepo.getExercisesSnapshot() + val (ok, error) = doParsePlan(pastedJson, exercises) + withContext(Dispatchers.Main) { + if (!ok) alertConfig = AlertData("Parse error", error ?: "Unknown error") + else step = Step.PREVIEW + } + } finally { + loading = false + } + } + }, modifier = Modifier.fillMaxWidth(), enabled = !loading && pastedJson.isNotBlank()) { Text(if (loading) "VALIDATING..." else "VALIDATE & PREVIEW") } + } + } + } + + if (step == Step.PREVIEW && parsedPlan != null) { + val pPlan = parsedPlan!! + item { + Column(Modifier.fillMaxWidth().background(c.card, RoundedCornerShape(18.dp)).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text("PLAN PREVIEW", color = c.text, fontWeight = FontWeight.Black, fontSize = IronLogType.title.fontSize.sp, letterSpacing = 1.5.sp) + + Column(Modifier.fillMaxWidth().background(c.accentSoft, RoundedCornerShape(12.dp)).border(1.dp, c.accentBorder, RoundedCornerShape(12.dp)).padding(14.dp)) { + Text(pPlan.name ?: "Plan", color = c.accent, fontWeight = FontWeight.Black, fontSize = IronLogType.section.fontSize.sp) + Text("${pPlan.days.size} days / ${pPlan.days.sumOf { it.exercises.size }} exercises", color = c.accent, fontWeight = FontWeight.Bold, fontSize = IronLogType.meta.fontSize.sp) + } + + if (autoAddedCount > 0) { + Text("$autoAddedCount new exercise(s) added to your library automatically", color = c.success, fontSize = IronLogType.meta.fontSize.sp, modifier = Modifier.fillMaxWidth().background(c.success.copy(alpha = 0.13f), RoundedCornerShape(10.dp)).padding(12.dp)) + } + if (smartNotes.isNotEmpty()) { + Column( + modifier = Modifier.fillMaxWidth().background(c.accentSoft, RoundedCornerShape(12.dp)).border(1.dp, c.accentBorder, RoundedCornerShape(12.dp)).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text("SMART PLAN ADJUSTMENTS", color = c.accent, fontWeight = FontWeight.ExtraBold, fontSize = IronLogType.meta.fontSize.sp) + smartNotes.forEach { note -> + Text("• $note", color = c.text, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + + if (parseWarnings.isNotEmpty()) { + Column(Modifier.fillMaxWidth().background(c.warning.copy(alpha = 0.07f), RoundedCornerShape(18.dp)).border(1.dp, c.warning.copy(alpha = 0.33f), RoundedCornerShape(18.dp)).padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("${parseWarnings.size} exercise(s) not in library - configure to add", color = c.accent, fontWeight = FontWeight.ExtraBold, fontSize = IronLogType.meta.fontSize.sp) + parseWarnings.forEach { name -> + val cfg = missingConfigs[name] ?: ("" to "Other") + Column(Modifier.fillMaxWidth().border(1.dp, c.warning.copy(alpha = 0.20f), RoundedCornerShape(12.dp)).padding(10.dp)) { + Text(name, color = c.accent, fontWeight = FontWeight.ExtraBold, fontSize = IronLogType.body.fontSize.sp) + Text("PRIMARY MUSCLE", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, fontWeight = FontWeight.Black, modifier = Modifier.padding(top = 6.dp, bottom = 4.dp)) + @OptIn(ExperimentalLayoutApi::class) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + MUSCLE_OPTIONS.forEach { m -> + FilterChip(selected = cfg.first == m, onClick = { missingConfigs = missingConfigs.toMutableMap().apply { this[name] = m to cfg.second } }, label = { Text(m) }) + } + } + Text("EQUIPMENT", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, fontWeight = FontWeight.Black, modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)) + @OptIn(ExperimentalLayoutApi::class) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + EQUIP_OPTIONS.forEach { eq -> + FilterChip(selected = cfg.second == eq, onClick = { missingConfigs = missingConfigs.toMutableMap().apply { this[name] = cfg.first to eq } }, label = { Text(eq) }) + } + } + } + } + Button(onClick = { + val names = missingConfigs.keys.filter { missingConfigs[it]?.first?.isNotBlank() == true } + if (names.isEmpty()) { + alertConfig = AlertData("Select muscle", "Choose a primary muscle for each exercise first.") + return@Button + } + scope.launch(Dispatchers.IO) { + loading = true + try { + for (n in names) { + val c = missingConfigs[n]!! + try { exerciseRepo.createCustomExercise(CreateExerciseInput(name = n, primaryMuscle = c.first, equipment = c.second, category = "strength")) } catch(e: Exception){} + } + val (ok, _) = doParsePlan(pastedJson, exerciseRepo.getExercisesSnapshot()) + withContext(Dispatchers.Main) { if (ok) { /* updated */ } } + } finally { + loading = false + } + } + }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp), colors = ButtonDefaults.buttonColors(containerColor = c.accent, contentColor = Color.Black)) { Text("ADD TO LIBRARY & RE-MATCH") } + } + } + if (parseNeedsReview.isNotEmpty()) { + Column( + modifier = Modifier.fillMaxWidth().background(c.warning.copy(alpha = 0.08f), RoundedCornerShape(18.dp)).border(1.dp, c.warning.copy(alpha = 0.35f), RoundedCornerShape(18.dp)).padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("NEEDS REVIEW (${parseNeedsReview.size})", color = c.warning, fontWeight = FontWeight.ExtraBold, fontSize = IronLogType.meta.fontSize.sp) + Text("These matches are ambiguous and were not auto-linked.", color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + parseNeedsReview.entries.forEach { (name, meta) -> + val selectedId = manualReviewResolution[name] + val selectedLabel = meta.candidates.firstOrNull { it.first == selectedId }?.second + Column( + modifier = Modifier.fillMaxWidth().border(1.dp, c.warning.copy(alpha = 0.25f), RoundedCornerShape(10.dp)).padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(name, color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.body.fontSize.sp) + Text(meta.suggestedText, color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + if (selectedLabel != null) { + Text("Selected: $selectedLabel", color = c.success, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Bold) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (!meta.suggestedId.isNullOrBlank()) { + Button(onClick = { + manualReviewResolution = manualReviewResolution.toMutableMap().also { it[name] = meta.suggestedId } + parseNeedsReview = parseNeedsReview.toMutableMap().also { it.remove(name) } + }) { Text("USE SUGGESTED") } + } + OutlinedButton(onClick = { pickingReviewName = name }) { Text("PICK MATCH") } + if (selectedId != null) { + TextButton(onClick = { + manualReviewResolution = manualReviewResolution.toMutableMap().also { it.remove(name) } + }) { Text("CLEAR") } + } + } + } + } + Text("Resolve by editing JSON names or using 'ADD TO LIBRARY & RE-MATCH' to create explicit custom exercises.", color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + } + } + + pPlan.days.forEach { day -> + Column(Modifier.fillMaxWidth().border(1.dp, c.faint, RoundedCornerShape(18.dp))) { + Row(Modifier.fillMaxWidth().padding(14.dp, 10.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(day.name ?: "Day", color = c.text, fontWeight = FontWeight.Black, fontSize = IronLogType.body.fontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Text("${day.exercises.size} ex", color = c.muted, fontWeight = FontWeight.Bold, fontSize = IronLogType.meta.fontSize.sp) + } + day.exercises.forEachIndexed { idx, ex -> + if (idx > 0) HorizontalDivider(color = c.faint) + Row(Modifier.fillMaxWidth().padding(14.dp, 7.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text("${if (ex.isWarmup == true) "(W) " else ""}${ex.name}", color = if (ex.exerciseId != null) c.text else c.accent, fontSize = IronLogType.body.fontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Text("${ex.sets}x${ex.reps}", color = c.muted, fontWeight = FontWeight.Bold, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + } + + val unresolvedReviewNames = parseNeedsReview.keys.filter { manualReviewResolution[it].isNullOrBlank() } + val canImport = parseWarnings.isEmpty() && unresolvedReviewNames.isEmpty() + Button(onClick = { + val resolvedPlan = pPlan.copy( + days = pPlan.days.map { day -> + day.copy( + exercises = day.exercises.map { ex -> + val name = ex.name.orEmpty() + val overrideId = manualReviewResolution[name] + if (!overrideId.isNullOrBlank()) ex.copy(exerciseId = overrideId) else ex + } + ) + } + ) + scope.launch(Dispatchers.IO) { + try { + planRepo.importFullPlan(resolvedPlan) + withContext(Dispatchers.Main) { + alertConfig = AlertData("Plan imported!", "\"${resolvedPlan.name}\" has been added to your plans.", "Done", onBack) + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { alertConfig = AlertData("Import failed", e.message ?: "Unknown error") } + } + } + }, enabled = canImport, modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { Text("IMPORT TO PLANS") } + if (!canImport) { + Text("Import is locked until unresolved and review-required exercises are resolved.", color = c.warning, fontSize = IronLogType.meta.fontSize.sp) + } + + OutlinedButton(onClick = { step = Step.PASTE }, modifier = Modifier.fillMaxWidth()) { Text("<- BACK TO PASTE") } + } + } + } + } + + alertConfig?.let { a -> + AlertDialog( + onDismissRequest = { alertConfig = null }, + title = { Text(a.title) }, + text = { Text(a.message) }, + confirmButton = { + TextButton(onClick = { alertConfig = null; a.onConfirm?.invoke() }) { + Text(a.confirmLabel) + } + } + ) + } + + pickingReviewName?.let { reviewName -> + val row = parseNeedsReview[reviewName] + if (row != null) { + AlertDialog( + onDismissRequest = { pickingReviewName = null }, + title = { Text("Pick match for $reviewName") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + row.candidates.forEach { (id, label) -> + TextButton( + onClick = { + manualReviewResolution = manualReviewResolution.toMutableMap().also { it[reviewName] = id } + parseNeedsReview = parseNeedsReview.toMutableMap().also { it.remove(reviewName) } + pickingReviewName = null + }, + modifier = Modifier.fillMaxWidth(), + ) { Text(label) } + } + } + }, + confirmButton = { + TextButton(onClick = { pickingReviewName = null }) { Text("CLOSE") } + } + ) + } + } +} + +private fun findExactExercise(name: String, exercises: List): LegacyExerciseShape? { + val key = normalizeExerciseNameKey(name) + if (key.isBlank()) return null + return exercises.firstOrNull { normalizeExerciseNameKey(it.name) == key } +} + +private fun hasExactExerciseName(name: String, exercises: List): Boolean = + findExactExercise(name, exercises) != null + +private fun customExerciseFromAiMetadata(ex: JSONObject, exName: String): CreateExerciseInput? { + val primary = ex.optString("primaryMuscle") + .ifBlank { ex.optJSONArray("primaryMuscles")?.optString(0).orEmpty() } + .trim() + .takeIf { it.isNotBlank() } + val equipment = ex.optString("equipment") + .trim() + .takeIf { it.isNotBlank() } + if (primary == null || equipment == null) return null + + val secondary = readAiStringList(ex, "secondaryMuscles") + .ifEmpty { readAiStringList(ex, "secondaryMuscle") } + .filterNot { it.equals(primary, ignoreCase = true) } + .distinct() + + return CreateExerciseInput( + name = exName, + primaryMuscle = primary, + equipment = equipment, + category = ex.optString("category").trim().ifBlank { "strength" }, + trackingType = ex.optString("trackingType").trim().takeIf { it.isNotBlank() }, + movementPattern = ex.optString("movementPattern").trim().takeIf { it.isNotBlank() }, + difficulty = ex.optString("difficulty").trim().takeIf { it.isNotBlank() }, + isBodyweight = ex.optBoolean("isBodyweight", equipment.equals("Bodyweight", ignoreCase = true)), + secondaryMuscles = secondary.takeIf { it.isNotEmpty() }, + ) +} + +private fun readAiStringList(obj: JSONObject, key: String): List { + val arr = obj.optJSONArray(key) + if (arr != null) { + return buildList { + for (i in 0 until arr.length()) { + arr.optString(i).trim().takeIf { it.isNotBlank() }?.let { add(it) } + } + } + } + return obj.optString(key) + .split(",", "/", "|") + .map { it.trim() } + .filter { it.isNotBlank() } +} + + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/plans/PlanEditorScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/plans/PlanEditorScreen.kt new file mode 100644 index 0000000..23af6aa --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/plans/PlanEditorScreen.kt @@ -0,0 +1,958 @@ +package com.ironlog.app.ui.screens.plans + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.ui.platform.LocalContext +import com.ironlog.app.domain.intelligence.CloudAiEngine +import com.ironlog.app.domain.intelligence.CloudAiKeyStore +import com.ironlog.app.ui.viewmodel.AppDataViewModel +import com.valentinilk.shimmer.shimmer +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ironlog.app.data.model.LegacyExerciseShape +import com.ironlog.app.data.model.PlanExerciseInput +import com.ironlog.app.data.repository.ExerciseRepository +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.model.UiPlanExercise +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.viewmodel.PlansViewModel +import com.ironlog.app.util.buildFilterChipOptions +import com.ironlog.app.util.getExerciseFilterSummary +import com.ironlog.app.util.matchesExerciseFilter +import com.ironlog.app.util.queryExerciseSearch +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +private val DAY_COLORS = listOf("#FF4500", "#0080FF", "#00C170", "#A020F0", "#FFD700", "#FF6B35", "#00BCD4") + +@Serializable +data class ProgramRules( + val progressionModel: String = "double_progression", + val blockLengthWeeks: Int = 4, + val currentWeek: Int = 1, + val deloadEveryWeeks: Int = 4, + val percent1RM: Int = 75, + val rpeTarget: Int = 8, + val rirTarget: Int = 2, +) + +private val rulesJson = Json { ignoreUnknownKeys = true } +private fun rulesKey(planId: String) = "program_rules:$planId" + +@Composable +fun PlanEditorScreen( + planId: String, + vm: PlansViewModel = viewModel(), + exerciseRepo: ExerciseRepository = ExerciseRepository(), + onBack: () -> Unit = {}, + onStartWorkout: (dayId: String) -> Unit = {} +) { + val c = useTheme() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val settingsRepo = remember { SettingsRepository() } + val appVm: AppDataViewModel = viewModel() + val appState by appVm.state.collectAsState() + val cloudSettings = appState.settings + val cloudApiKey = remember(cloudSettings.cloudAiProviderPreset) { + CloudAiKeyStore.load(context, cloudSettings.cloudAiProviderPreset) + } + val cloudConfigured = cloudApiKey.isNotBlank() + && cloudSettings.cloudAiBaseUrl.isNotBlank() + && cloudSettings.cloudAiModelName.isNotBlank() + val plans by vm.plans.collectAsState() + val plan = plans.firstOrNull { it.id == planId } + + var editDayIdx by remember(planId) { mutableStateOf(0) } + var showAddDay by remember { mutableStateOf(false) } + var dayName by remember { mutableStateOf("") } + var libSearch by remember { mutableStateOf("") } + var debouncedLibSearch by remember { mutableStateOf("") } + var libMuscle by remember { mutableStateOf(null) } + var allExercises by remember { mutableStateOf>(emptyList()) } + + var editingPlanName by remember { mutableStateOf(false) } + var planNameInput by remember { mutableStateOf("") } + + var editingDayId by remember { mutableStateOf(null) } + var dayNameInput by remember { mutableStateOf("") } + + var rules by remember { mutableStateOf(ProgramRules()) } + + LaunchedEffect(Unit) { allExercises = exerciseRepo.getExercisesSnapshot() } + LaunchedEffect(libSearch) { delay(170); debouncedLibSearch = libSearch } + LaunchedEffect(planId) { + val raw = settingsRepo.getString(rulesKey(planId)) + if (!raw.isNullOrBlank()) { + runCatching { rules = rulesJson.decodeFromString(raw) } + } + } + + fun saveRules(next: ProgramRules) { + rules = next + scope.launch { + runCatching { settingsRepo.setSetting(rulesKey(planId), rulesJson.encodeToString(next)) } + } + } + + if (plan == null) { + Column(Modifier.fillMaxSize().background(c.bg).padding(16.dp)) { Text("Plan not found", color = c.text) } + return + } + + val activeDay = plan.days.getOrNull(editDayIdx) + val libMuscles = remember(allExercises) { buildFilterChipOptions(allExercises, includeCategory = false, includeEquipment = false) } + val filteredExercises = remember(allExercises, debouncedLibSearch, libMuscle) { + val base = allExercises.filter { ex -> libMuscle == null || matchesExerciseFilter(ex, libMuscle) } + queryExerciseSearch(base, debouncedLibSearch) + } + + val lazyListState = rememberLazyListState() + val reorderState = rememberReorderableLazyListState(lazyListState) { from, to -> + val offset = 3 + val fromIdx = from.index - offset + val toIdx = to.index - offset + if (activeDay != null && fromIdx in activeDay.exercises.indices && toIdx in activeDay.exercises.indices) { + val ids = activeDay.exercises.map { it.id }.toMutableList() + java.util.Collections.swap(ids, fromIdx, toIdx) + vm.reorderExercises(activeDay.id, ids) + } + } + + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize().background(c.bg).statusBarsPadding().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(top = 16.dp, bottom = 80.dp) + ) { + item { ScreenHeader(title = "EDIT PLAN", onBack = onBack) } + item { + if (editingPlanName) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = planNameInput, + onValueChange = { planNameInput = it }, + label = { Text("Plan name") }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + Button(onClick = { + if (planNameInput.isNotBlank()) vm.renamePlan(plan.id, planNameInput.trim()) + editingPlanName = false + }, colors = ButtonDefaults.buttonColors(containerColor = c.accent)) { Text("SAVE", color = c.textOnAccent) } + } + } else { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { + planNameInput = plan.name; editingPlanName = true + }) { + Text(plan.name, color = c.text, fontWeight = FontWeight.Black, fontSize = IronLogType.title.fontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Spacer(Modifier.width(6.dp)) + Text("✏", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + } + } + Spacer(Modifier.height(8.dp)) + val isDeloadWeek = rules.deloadEveryWeeks > 0 && rules.currentWeek % rules.deloadEveryWeeks == 0 + androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + itemsIndexed(plan.days) { idx, day -> + AssistChip( + onClick = { editDayIdx = idx }, + label = { + if (isDeloadWeek) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Text(day.name, color = if (idx == editDayIdx) c.accent else c.text) + Text( + "DELOAD", + color = c.warning, + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.3.sp, + ) + } + } else { + Text(day.name, color = if (idx == editDayIdx) c.accent else c.text) + } + }, + ) + } + item { + AssistChip(onClick = { showAddDay = !showAddDay }, label = { Text("+ Day") }) + } + } + if (showAddDay) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) { + OutlinedTextField(dayName, { dayName = it }, placeholder = { Text("Day name") }, modifier = Modifier.weight(1f), singleLine = true) + Button(onClick = { + if (dayName.trim().isNotEmpty()) { + vm.addDay(plan.id, dayName.trim(), DAY_COLORS[plan.days.size % DAY_COLORS.size]) + dayName = ""; showAddDay = false + } + }, colors = ButtonDefaults.buttonColors(containerColor = c.accent)) { Text("ADD", color = c.textOnAccent) } + } + } + } + + item { + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + shape = RoundedCornerShape(IronLogRadius.xl.dp), + ) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Column { + Text("PROGRAM RULES", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, fontWeight = FontWeight(700), letterSpacing = 1.2.sp) + Text("Week ${rules.currentWeek} of ${rules.blockLengthWeeks}", color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.title.fontSize.sp) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + Modifier + .defaultMinSize(minWidth = 44.dp, minHeight = 44.dp) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { + val max = rules.blockLengthWeeks + val prev = ((rules.currentWeek - 2 + max) % max) + 1 + saveRules(rules.copy(currentWeek = prev)) + }.padding(horizontal = 10.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { Text("−", color = c.accent, fontWeight = FontWeight.Bold) } + Box( + Modifier + .defaultMinSize(minWidth = 44.dp, minHeight = 44.dp) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { + val max = rules.blockLengthWeeks + val next = (rules.currentWeek % max) + 1 + saveRules(rules.copy(currentWeek = next)) + }.padding(horizontal = 10.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { Text("+", color = c.accent, fontWeight = FontWeight.Bold) } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("double_progression" to "Double", "linear" to "Linear", "percent_1rm" to "%1RM", "rpe_rir" to "RPE/RIR").forEach { (id, label) -> + val active = rules.progressionModel == id + Box( + Modifier + .background(if (active) c.accent else c.surface, RoundedCornerShape(IronLogRadius.full.dp)) + .border(1.dp, if (active) c.accent else c.cardBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { saveRules(rules.copy(progressionModel = id)) } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { Text(label, color = if (active) c.textOnAccent else c.muted, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight(600)) } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(3, 4, 5, 6).forEach { weeks -> + val active = rules.blockLengthWeeks == weeks + Box( + Modifier + .background(if (active) c.accent else c.surface, RoundedCornerShape(IronLogRadius.full.dp)) + .border(1.dp, if (active) c.accent else c.cardBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { saveRules(rules.copy(blockLengthWeeks = weeks, currentWeek = minOf(rules.currentWeek, weeks), deloadEveryWeeks = weeks)) } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { Text("${weeks}wk", color = if (active) c.textOnAccent else c.muted, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight(600)) } + } + } + + if (rules.progressionModel == "percent_1rm") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(65, 70, 75, 80, 85).forEach { pct -> + val active = rules.percent1RM == pct + Box( + Modifier + .background(if (active) c.accent else c.surface, RoundedCornerShape(IronLogRadius.full.dp)) + .border(1.dp, if (active) c.accent else c.cardBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { saveRules(rules.copy(percent1RM = pct)) } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { Text("$pct%", color = if (active) c.textOnAccent else c.muted, fontSize = IronLogType.meta.fontSize.sp) } + } + } + } + + if (rules.progressionModel == "rpe_rir") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(7, 8, 9).forEach { rpe -> + val active = rules.rpeTarget == rpe + Box( + Modifier + .background(if (active) c.accent else c.surface, RoundedCornerShape(IronLogRadius.full.dp)) + .border(1.dp, if (active) c.accent else c.cardBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { saveRules(rules.copy(rpeTarget = rpe, rirTarget = maxOf(0, 10 - rpe))) } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { Text("RPE $rpe", color = if (active) c.textOnAccent else c.muted, fontSize = IronLogType.meta.fontSize.sp) } + } + } + } + + Text("Active workouts use these as suggested targets only. Logged sets are never rewritten.", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + + if (activeDay != null) { + item { + val dayId = activeDay.id + var dayMenuExpanded by remember(dayId) { mutableStateOf(false) } + var showCopyDayPicker by remember(dayId) { mutableStateOf(false) } + var showAiReviewSheet by remember(dayId) { mutableStateOf(false) } + var aiReviewText by remember(dayId) { mutableStateOf(null) } + var aiReviewLoading by remember(dayId) { mutableStateOf(false) } + var showDeloadConfirm by remember(dayId) { mutableStateOf(false) } + var deloadCreating by remember(dayId) { mutableStateOf(false) } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + if (editingDayId == activeDay.id) { + OutlinedTextField( + value = dayNameInput, + onValueChange = { dayNameInput = it }, + modifier = Modifier.weight(1f), + singleLine = true, + label = { Text("Day name") }, + ) + Spacer(Modifier.width(6.dp)) + Button(onClick = { + if (dayNameInput.isNotBlank()) vm.renameDay(activeDay.id, dayNameInput.trim()) + editingDayId = null + }, colors = ButtonDefaults.buttonColors(containerColor = c.accent)) { Text("SAVE", color = c.textOnAccent) } + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(activeDay.name, color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.section.fontSize.sp) + Spacer(Modifier.width(4.dp)) + Box { + IconButton(onClick = { dayMenuExpanded = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = "Menu", tint = c.muted) + } + DropdownMenu(dayMenuExpanded, onDismissRequest = { dayMenuExpanded = false }, modifier = Modifier.background(c.surface)) { + DropdownMenuItem(text = { Text("Rename Day", color = c.text) }, onClick = { dayMenuExpanded = false; dayNameInput = activeDay.name; editingDayId = activeDay.id }) + DropdownMenuItem(text = { Text("Copy Day to…", color = c.text) }, onClick = { dayMenuExpanded = false; showCopyDayPicker = true }) + DropdownMenuItem(text = { Text("Delete Day", color = c.danger) }, onClick = { + dayMenuExpanded = false + vm.removeDay(activeDay.id) + if (editDayIdx >= plan.days.size - 1) editDayIdx = maxOf(0, plan.days.size - 2) + }) + if (cloudConfigured) { + DropdownMenuItem( + text = { Text("Review with Cloud AI", color = c.accent) }, + onClick = { + dayMenuExpanded = false + aiReviewText = null + showAiReviewSheet = true + } + ) + DropdownMenuItem( + text = { Text("Generate Deload Day", color = c.accent) }, + onClick = { + dayMenuExpanded = false + showDeloadConfirm = true + } + ) + } + } + } + } + if (showCopyDayPicker) { + val otherDays = plan.days.filter { it.id != activeDay.id } + AlertDialog( + onDismissRequest = { showCopyDayPicker = false }, + title = { Text("Copy Day to…", color = c.text) }, + text = { + if (otherDays.isEmpty()) { + Text("No other days available.", color = c.muted) + } else { + Column { + otherDays.forEach { targetDay -> + Text( + text = targetDay.name, + color = c.text, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier + .fillMaxWidth() + .clickable { + vm.copyDay(activeDay.id, targetDay.id) + showCopyDayPicker = false + } + .padding(vertical = 12.dp) + ) + HorizontalDivider(color = c.cardBorder.copy(alpha = 0.5f)) + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = { showCopyDayPicker = false }) { + Text("Cancel", color = c.muted) + } + }, + containerColor = c.surface, + ) + } + Button( + onClick = { onStartWorkout(activeDay.id) }, + colors = ButtonDefaults.buttonColors(containerColor = c.accent), + shape = RoundedCornerShape(IronLogRadius.full.dp) + ) { Text("START", color = c.textOnAccent, fontWeight = FontWeight.Black) } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + DAY_COLORS.forEach { hex -> + val swatch = runCatching { Color(android.graphics.Color.parseColor(hex)) }.getOrDefault(c.accent) + val selected = activeDay.color.equals(hex, ignoreCase = true) + Box( + modifier = Modifier + .size(44.dp) + .clickable { vm.updateDayColor(activeDay.id, hex) }, + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(28.dp) + .clip(CircleShape) + .background(swatch) + .border( + width = if (selected) 2.dp else 1.dp, + color = if (selected) c.text else c.cardBorder, + shape = CircleShape, + ) + ) + } + } + } + } + + @OptIn(ExperimentalMaterial3Api::class) + if (showAiReviewSheet) { + LaunchedEffect(showAiReviewSheet) { + if (aiReviewText != null) return@LaunchedEffect + aiReviewLoading = true + aiReviewText = CloudAiEngine.askDayEvaluation( + baseUrl = cloudSettings.cloudAiBaseUrl, + apiKey = cloudApiKey, + modelName = cloudSettings.cloudAiModelName, + apiFormat = cloudSettings.cloudAiApiFormat, + dayName = activeDay.name, + exerciseNames = activeDay.exercises.map { it.name }, + goalMode = cloudSettings.goalMode, + ) + aiReviewLoading = false + } + + ModalBottomSheet( + onDismissRequest = { showAiReviewSheet = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = c.card, + ) { + Column( + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "CLOUD AI REVIEW", + color = c.accent, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + Text(activeDay.name, color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.section.fontSize.sp) + Text( + activeDay.exercises.joinToString(" · ") { it.name }, + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + if (aiReviewLoading) { + Column(Modifier.shimmer(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + repeat(3) { idx -> + Box( + Modifier + .fillMaxWidth(if (idx == 2) 0.6f else 1f) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(c.faint) + ) + } + } + } else { + Text( + aiReviewText ?: "No evaluation available.", + color = c.text, + fontSize = IronLogType.body.fontSize.sp, + lineHeight = IronLogType.body.lineHeight.sp, + ) + Row( + Modifier + .clickable { + aiReviewText = null + aiReviewLoading = true + scope.launch { + aiReviewText = CloudAiEngine.askDayEvaluation( + baseUrl = cloudSettings.cloudAiBaseUrl, + apiKey = cloudApiKey, + modelName = cloudSettings.cloudAiModelName, + apiFormat = cloudSettings.cloudAiApiFormat, + dayName = activeDay.name, + exerciseNames = activeDay.exercises.map { it.name }, + goalMode = cloudSettings.goalMode, + ) + aiReviewLoading = false + } + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon(Icons.Outlined.Refresh, null, tint = c.muted, modifier = Modifier.size(12.dp)) + Text("Regenerate", color = c.muted, fontSize = IronLogType.micro.fontSize.sp) + } + } + Spacer(Modifier.height(16.dp)) + } + } + } + + if (showDeloadConfirm) { + AlertDialog( + onDismissRequest = { showDeloadConfirm = false }, + title = { Text("Generate Deload Day", color = c.text) }, + text = { + Text( + "This will create \"${activeDay.name} (Deload)\" with the same exercises at half the sets and a 60%-weight note.", + color = c.subtext, + fontSize = IronLogType.body.fontSize.sp, + ) + }, + confirmButton = { + Button( + onClick = { + showDeloadConfirm = false + if (!deloadCreating) { + deloadCreating = true + scope.launch { + val newDayId = vm.addDayAndGetId( + planId = planId, + name = "${activeDay.name} (Deload)", + color = activeDay.color.ifBlank { "#555555" }, + ) + try { + activeDay.exercises.forEach { ex -> + if (ex.exerciseId.isBlank()) return@forEach + vm.addExerciseToDay( + dayId = newDayId, + exerciseId = ex.exerciseId, + meta = PlanExerciseInput( + sets = maxOf(2, ex.sets / 2), + reps = ex.reps, + restSeconds = ex.restSeconds, + notes = "Deload: use 60% of working weight", + ), + ) + } + } finally { + deloadCreating = false + } + } + } + }, + colors = ButtonDefaults.buttonColors(containerColor = c.accent), + ) { + Text("Generate", color = c.textOnAccent) + } + }, + dismissButton = { + TextButton(onClick = { showDeloadConfirm = false }) { + Text("Cancel", color = c.muted) + } + }, + containerColor = c.card, + ) + } + } + + if (activeDay.exercises.isEmpty()) { + item { + Column( + Modifier.fillMaxWidth().padding(vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("No exercises yet", color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.section.fontSize.sp) + Text("Search the library below to start building your routine.", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + } + } + } + + itemsIndexed(activeDay.exercises, key = { _, ex -> ex.id }) { index, ex -> + val alternatives = remember(ex.id, allExercises) { buildAlternativesForPlanExercise(ex, allExercises) } + ReorderableItem(reorderState, key = ex.id) { isDragging -> + PlanExerciseRow( + ex = ex, + alternatives = alternatives, + index = index, + isDragging = isDragging, + onRemove = { vm.removeExercise(ex.id) }, + onSuperset = { group -> vm.updateExercise(ex.id, PlanExerciseInput(supersetGroup = group ?: "")) }, + onSetsChange = { sets -> vm.updateExercise(ex.id, PlanExerciseInput(sets = sets)) }, + onRepsChange = { reps -> vm.updateExercise(ex.id, PlanExerciseInput(reps = reps)) }, + onRestChange = { rest -> vm.updateExercise(ex.id, PlanExerciseInput(restSeconds = rest)) }, + onNotesChange = { notes -> vm.updateExercise(ex.id, PlanExerciseInput(notes = notes)) }, + onSwapExercise = { alt -> vm.updateExercise(ex.id, PlanExerciseInput(exerciseId = alt.id)) }, + dragModifier = Modifier.draggableHandle() + ) + } + } + + item { + Spacer(Modifier.height(8.dp)) + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + ) { + Column { + // Search row + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Outlined.Search, contentDescription = null, tint = c.muted, modifier = Modifier.size(15.dp)) + BasicTextField( + value = libSearch, + onValueChange = { libSearch = it }, + modifier = Modifier.weight(1f), + singleLine = true, + textStyle = TextStyle(color = c.text, fontSize = IronLogType.body.fontSize.sp), + cursorBrush = SolidColor(c.accent), + decorationBox = { inner -> + if (libSearch.isEmpty()) Text("Search exercises…", color = c.faint, fontSize = IronLogType.body.fontSize.sp) + inner() + }, + ) + if (libSearch.isNotEmpty()) { + Text("✕", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, modifier = Modifier.clickable { libSearch = "" }.padding(8.dp)) + } + Text("ADD EXERCISE", color = c.accent, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.5.sp) + } + HorizontalDivider(color = c.cardBorder) + // Filter chips + LazyRow( + modifier = Modifier.fillMaxWidth().padding(vertical = 7.dp), + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + item { + val isAll = libMuscle == null + Box( + Modifier + .background(if (isAll) c.accent.copy(alpha = 0.18f) else c.surface, RoundedCornerShape(IronLogRadius.full.dp)) + .border(1.dp, if (isAll) c.accent else c.cardBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { libMuscle = null } + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { Text("All", color = if (isAll) c.accent else c.muted, fontSize = IronLogType.meta.fontSize.sp, fontWeight = if (isAll) FontWeight.Bold else FontWeight.Normal) } + } + items(libMuscles, key = { it }) { m -> + val isActive = libMuscle == m + Box( + Modifier + .background(if (isActive) c.accent.copy(alpha = 0.18f) else c.surface, RoundedCornerShape(IronLogRadius.full.dp)) + .border(1.dp, if (isActive) c.accent else c.cardBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { libMuscle = if (isActive) null else m } + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { Text(m, color = if (isActive) c.accent else c.muted, fontSize = IronLogType.meta.fontSize.sp, fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal) } + } + } + HorizontalDivider(color = c.cardBorder) + // Compact exercise rows + val displayExercises = filteredExercises.take(60) + if (displayExercises.isEmpty()) { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + Text("No exercises found", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + } + } else { + displayExercises.forEachIndexed { idx, ex -> + Row( + Modifier.fillMaxWidth() + .clickable { + vm.addExerciseToDay( + activeDay.id, + ex.id, + PlanExerciseInput(exerciseId = ex.id, sets = 3, reps = if (inferTrackingType(ex) == "weight_reps") "10" else "60", restSeconds = 90), + ) + } + .padding(horizontal = 12.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(ex.name, color = c.text, fontSize = IronLogType.body.fontSize.sp, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + Spacer(Modifier.width(8.dp)) + Text(getExerciseFilterSummary(ex), color = c.muted, fontSize = IronLogType.meta.fontSize.sp, maxLines = 1) + } + if (idx < displayExercises.lastIndex) { + HorizontalDivider(color = c.cardBorder.copy(alpha = 0.45f), modifier = Modifier.padding(horizontal = 12.dp)) + } + } + } + Spacer(Modifier.height(4.dp)) + } + } + } + } + } +} + +@Composable +private fun PlanExerciseRow( + ex: UiPlanExercise, + alternatives: List, + index: Int, + isDragging: Boolean, + onRemove: () -> Unit, + onSuperset: (String?) -> Unit, + onSetsChange: (Int) -> Unit, + onRepsChange: (String) -> Unit, + onRestChange: (Int) -> Unit, + onNotesChange: (String) -> Unit, + onSwapExercise: (LegacyExerciseShape) -> Unit, + dragModifier: Modifier = Modifier +) { + val c = useTheme() + val scope = rememberCoroutineScope() + val settingsRepo = remember { SettingsRepository() } + var setsInput by remember(ex.id, ex.sets) { mutableStateOf(ex.sets.toString()) } + var repsInput by remember(ex.id, ex.reps) { mutableStateOf(ex.reps) } + var restInput by remember(ex.id, ex.restSeconds) { mutableStateOf(ex.restSeconds.toString()) } + var notesInput by remember(ex.id, ex.notes) { mutableStateOf(ex.notes) } + var notesExpanded by remember { mutableStateOf(ex.notes.isNotBlank()) } + var alternativesExpanded by remember { mutableStateOf(false) } + var showMenu by remember { mutableStateOf(false) } + // Per-exercise progression override (empty string = use plan default) + var progressionOverride by remember(ex.id) { mutableStateOf("") } + LaunchedEffect(ex.id) { + progressionOverride = settingsRepo.getString("ex_prog:${ex.id}").orEmpty() + } + + Card( + colors = CardDefaults.cardColors(containerColor = if (isDragging) c.surface else c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, if (isDragging) c.accent else c.cardBorder), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Text("${index + 1}. ${ex.name}", color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.body.fontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (ex.supersetGroup.isNotBlank()) { + Box( + Modifier + .background(c.accent.copy(alpha = 0.15f), RoundedCornerShape(IronLogRadius.full.dp)) + .border(1.dp, c.accent.copy(alpha = 0.4f), RoundedCornerShape(IronLogRadius.full.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { Text(ex.supersetGroup, color = c.accent, fontSize = IronLogType.eyebrow.fontSize.sp, fontWeight = FontWeight(700)) } + } + } + } + Box { + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = "Menu", tint = c.muted) + } + DropdownMenu(showMenu, onDismissRequest = { showMenu = false }, modifier = Modifier.background(c.surface)) { + DropdownMenuItem(text = { Text(if (alternativesExpanded) "Hide Alternatives" else "Replace Exercise", color = c.text) }, onClick = { showMenu = false; alternativesExpanded = !alternativesExpanded }) + DropdownMenuItem(text = { Text("Remove", color = c.danger) }, onClick = { showMenu = false; onRemove() }) + } + } + Box(dragModifier.padding(4.dp)) { + Icon(Icons.Outlined.Menu, contentDescription = "Reorder", tint = c.faint, modifier = Modifier.size(22.dp)) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = setsInput, + onValueChange = { setsInput = it; it.toIntOrNull()?.takeIf { v -> v > 0 }?.let(onSetsChange) }, + label = { Text("Sets", fontSize = IronLogType.meta.fontSize.sp) }, + textStyle = TextStyle(fontSize = IronLogType.body.fontSize.sp, color = c.text), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + singleLine = true, + ) + OutlinedTextField( + value = repsInput, + onValueChange = { repsInput = it; if (it.isNotBlank()) onRepsChange(it) }, + label = { Text("Reps", fontSize = IronLogType.meta.fontSize.sp) }, + textStyle = TextStyle(fontSize = IronLogType.body.fontSize.sp, color = c.text), + modifier = Modifier.weight(1.2f), + singleLine = true, + ) + OutlinedTextField( + value = restInput, + onValueChange = { restInput = it; it.toIntOrNull()?.takeIf { v -> v >= 0 }?.let(onRestChange) }, + label = { Text("Rest s", fontSize = IronLogType.meta.fontSize.sp) }, + textStyle = TextStyle(fontSize = IronLogType.body.fontSize.sp, color = c.text), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + singleLine = true, + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(top = 4.dp)) { + listOf(null, "A", "B", "C").forEach { group -> + Text( + group ?: "NO SS", + color = if ((ex.supersetGroup.ifBlank { null }) == group) c.accent else c.text, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier.clickable { onSuperset(group) }.padding(vertical = 10.dp, horizontal = 4.dp), + ) + } + } + + // Per-exercise progression model override + Row( + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 2.dp), + ) { + Text("Prog:", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + listOf("" to "Plan", "double_progression" to "2x", "linear" to "Lin", "percent_1rm" to "%1RM", "rpe_rir" to "RPE").forEach { (id, label) -> + val isActive = progressionOverride == id + Box( + Modifier + .background(if (isActive) c.accent.copy(alpha = 0.18f) else Color.Transparent, RoundedCornerShape(IronLogRadius.full.dp)) + .border(1.dp, if (isActive) c.accent else c.cardBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { + progressionOverride = id + scope.launch { settingsRepo.setSetting("ex_prog:${ex.id}", id) } + } + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + Text( + label, + color = if (isActive) c.accent else c.muted, + fontSize = 9.sp, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + ) + } + } + } + + if (notesExpanded) { + OutlinedTextField( + value = notesInput, + onValueChange = { notesInput = it; onNotesChange(it) }, + label = { Text("Note") }, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + maxLines = 2, + ) + } else { + Text( + if (notesInput.isBlank()) "+ Add note" else "📝 $notesInput", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier.clickable { notesExpanded = true }.padding(top = 4.dp, bottom = 10.dp), + ) + } + + if (alternativesExpanded && alternatives.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + alternatives.take(8).forEach { alt -> + Row( + Modifier.fillMaxWidth().clickable { onSwapExercise(alt.exercise); alternativesExpanded = false }.padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(alt.exercise.name, color = c.text, fontSize = IronLogType.body.fontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Text(alt.reason, color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + } + } +} + +private data class ExerciseAlternative(val exercise: LegacyExerciseShape, val reason: String) + +private fun buildAlternativesForPlanExercise( + ex: UiPlanExercise, + allExercises: List, +): List { + val current = allExercises.firstOrNull { it.id == ex.exerciseId } ?: return emptyList() + val sameMuscle = allExercises + .filter { it.id != current.id && it.primaryMuscle.equals(current.primaryMuscle, ignoreCase = true) } + .take(3) + .map { ExerciseAlternative(it, "Same Muscle") } + val sameEquipment = allExercises + .filter { it.id != current.id && it.equipment.equals(current.equipment, ignoreCase = true) } + .take(3) + .map { ExerciseAlternative(it, "Same Equipment") } + val fallback = allExercises + .filter { it.id != current.id && it.category.equals(current.category, ignoreCase = true) } + .take(3) + .map { ExerciseAlternative(it, "Fallback") } + return (sameMuscle + sameEquipment + fallback) + .distinctBy { it.exercise.id } +} + +fun inferTrackingType(exercise: LegacyExerciseShape): String { + val tracking = exercise.trackingType.trim() + if (tracking.isNotBlank()) return tracking + val signal = "${exercise.category.lowercase()} ${exercise.name.lowercase()} ${exercise.movementPattern.orEmpty().lowercase()}" + return when { + Regex("stretch|hold|plank|mobility|isometric").containsMatchIn(signal) -> "duration" + Regex("treadmill|bike|erg|rower|run|walk|ski|carry|conditioning|cardio|distance|locomotion").containsMatchIn(signal) -> "duration_distance" + else -> "weight_reps" + } +} + +fun buildPlanExerciseFromLibrary(exercise: LegacyExerciseShape, previous: UiPlanExercise? = null): UiPlanExercise { + val trackingType = inferTrackingType(exercise) + return UiPlanExercise( + id = previous?.id ?: exercise.id, + exerciseId = exercise.id, + name = exercise.name, + sets = previous?.sets ?: 3, + reps = previous?.reps ?: if (trackingType == "weight_reps") "10" else "60", + restSeconds = previous?.restSeconds ?: 90, + supersetGroup = previous?.supersetGroup ?: "", + isWarmup = previous?.isWarmup ?: false, + notes = previous?.notes ?: "", + ) +} + + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/plans/PlanQrScanScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/plans/PlanQrScanScreen.kt new file mode 100644 index 0000000..6c21ae5 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/plans/PlanQrScanScreen.kt @@ -0,0 +1,195 @@ +package com.ironlog.app.ui.screens.plans + +import android.Manifest +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.core.content.ContextCompat +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import com.ironlog.app.domain.sharing.PlanQrCodec +import com.ironlog.app.ui.model.UiPlan +import java.util.concurrent.Executors + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalGetImage::class, ExperimentalPermissionsApi::class) +@androidx.annotation.OptIn(ExperimentalGetImage::class) +@Composable +fun PlanQrScanScreen( + onPlanScanned: (UiPlan) -> Unit, + onBack: () -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val codec = remember { PlanQrCodec() } + + val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA) + var scanError: String? by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + if (!cameraPermission.status.isGranted) cameraPermission.launchPermissionRequest() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Scan Plan QR") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + }, + ) + } + ) { padding -> + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + when { + !cameraPermission.status.isGranted -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (cameraPermission.status.shouldShowRationale) { + // User denied once but system dialog can still appear + Text("Camera access is needed to scan QR codes.") + Button(onClick = { cameraPermission.launchPermissionRequest() }) { + Text("Grant Permission") + } + } else { + // First-time (LaunchedEffect already fired) or permanently denied + Text("Camera permission is required. Please enable it in Settings.") + Button(onClick = { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) + }) { + Text("Open Settings") + } + } + } + } + + else -> { + val executor = remember { Executors.newSingleThreadExecutor() } + DisposableEffect(Unit) { onDispose { executor.shutdownNow() } } + var scanned by remember { mutableStateOf(false) } + + AndroidView( + factory = { ctx -> + val previewView = PreviewView(ctx) + val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val barcodeScanner = BarcodeScanning.getClient() + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { imageAnalysis -> + imageAnalysis.setAnalyzer(executor) { imageProxy -> + if (scanned) { + imageProxy.close() + return@setAnalyzer + } + val mediaImage = imageProxy.image + if (mediaImage != null) { + val img = InputImage.fromMediaImage( + mediaImage, imageProxy.imageInfo.rotationDegrees + ) + barcodeScanner.process(img) + .addOnSuccessListener { barcodes -> + barcodes.firstOrNull { it.format == Barcode.FORMAT_QR_CODE } + ?.rawValue + ?.let { raw -> + val plan = codec.decodeFromPayload(raw) + if (plan != null && !scanned) { + scanned = true + onPlanScanned(plan) + } else if (plan == null) { + scanError = "QR code is not an IronLog plan." + } + } + } + .addOnCompleteListener { imageProxy.close() } + } else { + imageProxy.close() + } + } + } + + runCatching { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis, + ) + } + }, ContextCompat.getMainExecutor(ctx)) + previewView + }, + modifier = Modifier.fillMaxSize(), + ) + + // Scan frame overlay + Box( + modifier = Modifier + .size(250.dp) + .border(3.dp, Color.White, RoundedCornerShape(12.dp)) + ) + + // Status text overlay at bottom + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.50f)) + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (scanError != null) { + Text(scanError!!, color = MaterialTheme.colorScheme.error, maxLines = 2, overflow = TextOverflow.Ellipsis) + } else { + Text("Point camera at a plan QR code", color = Color.White) + } + } + } + } + } + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/plans/PlanQrShareSheet.kt b/app/src/main/java/com/ironlog/app/ui/screens/plans/PlanQrShareSheet.kt new file mode 100644 index 0000000..eb35a29 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/plans/PlanQrShareSheet.kt @@ -0,0 +1,133 @@ +package com.ironlog.app.ui.screens.plans + +import android.content.Intent +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.domain.sharing.PlanQrCodec +import com.ironlog.app.ui.model.UiPlan +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Share sheet for a [UiPlan]. + * + * Primary action: Android share intent (works for any plan size). + * Secondary action: copy deep-link payload to clipboard. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlanQrShareSheet( + plan: UiPlan, + onDismiss: () -> Unit, + @Suppress("UNUSED_PARAMETER") onShareText: (payload: String) -> Unit = {}, +) { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val codec = remember { PlanQrCodec() } + val scope = rememberCoroutineScope() + + var payload by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(true) } + var copied by remember { mutableStateOf(false) } + + LaunchedEffect(plan.id) { + isLoading = true + withContext(Dispatchers.Default) { + payload = runCatching { codec.encodeToPayload(plan) }.getOrDefault("") + } + isLoading = false + } + + val deepLink = "ironlog://import?plan=$payload" + + fun shareViaAndroid() { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, "IronLog Plan: ${plan.name}") + putExtra(Intent.EXTRA_TEXT, deepLink) + } + context.startActivity(Intent.createChooser(intent, "Share Plan")) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + "Share Plan", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + plan.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.size(36.dp)) + } else { + // ── Primary: share via any app ──────────────────────────── + Button( + onClick = { shareViaAndroid() }, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(12.dp), + ) { + Icon(Icons.Outlined.Share, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Share Plan", fontWeight = FontWeight.Bold) + } + + // ── Secondary: copy link to clipboard ───────────────────── + OutlinedButton( + onClick = { + clipboard.setText(AnnotatedString(deepLink)) + copied = true + scope.launch { + kotlinx.coroutines.delay(2000) + copied = false + } + }, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(12.dp), + ) { + Icon(Icons.Outlined.ContentCopy, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(if (copied) "Copied!" else "Copy Link") + } + + Text( + "The recipient opens the link in IronLog to import your plan.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + ) + } + + Spacer(Modifier.height(4.dp)) + } + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/plans/PlansScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/plans/PlansScreen.kt new file mode 100644 index 0000000..1f19e98 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/plans/PlansScreen.kt @@ -0,0 +1,687 @@ +package com.ironlog.app.ui.screens.plans + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.core.content.FileProvider +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ironlog.app.data.model.FullPlanDay +import com.ironlog.app.data.model.FullPlanObject +import com.ironlog.app.data.model.LegacyExerciseShape +import com.ironlog.app.data.model.PlanExerciseInput +import com.ironlog.app.services.ShareService +import com.ironlog.app.ui.components.PageHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.UiPlan +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogThemeTokens +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.viewmodel.PlansViewModel +import com.ironlog.app.ui.viewmodel.StatsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +@Composable +fun PlansScreen( + vm: PlansViewModel = viewModel(), + statsVm: StatsViewModel = viewModel(), + onOpenPlan: (String) -> Unit = {}, + onStartWorkout: (planId: String, dayId: String) -> Unit = { _, _ -> }, + onOpenProgramPicker: () -> Unit = {}, + onOpenAIPlan: () -> Unit = {}, + onOpenQrScan: () -> Unit = {}, +) { + val c = useTheme() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val rawPlans by vm.plans.collectAsState() + val statsState by statsVm.state.collectAsState() + val history = statsState.history + + // Local state for dragging overrides. + // Keep this stable across upstream emissions so reorder interaction doesn't snap/reset. + var orderedIds by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(rawPlans) { + val rawIds = rawPlans.map { it.id } + orderedIds = when { + orderedIds.isEmpty() -> rawIds + else -> { + val existing = orderedIds.filter { it in rawIds } + val added = rawIds.filter { it !in existing } + existing + added + } + } + } + val plans = remember(rawPlans, orderedIds) { + if (orderedIds.isEmpty()) rawPlans + else orderedIds.mapNotNull { id -> rawPlans.find { it.id == id } } + } + + var showImport by remember { mutableStateOf(false) } + var status by remember { mutableStateOf("") } + var planToDelete by remember { mutableStateOf(null) } + var qrTargetPlan by remember { mutableStateOf(null) } + + // File picker for plan import + val planImportPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch(Dispatchers.IO) { + val text = runCatching { + context.contentResolver.openInputStream(uri)?.bufferedReader()?.readText().orEmpty() + }.getOrElse { e -> + withContext(Dispatchers.Main) { status = "Could not read file: ${e.message}" } + return@launch + } + val result = runCatching { parsePlansJson(text) } + withContext(Dispatchers.Main) { + result + .onFailure { status = "Import failed: ${it.message}" } + .onSuccess { list -> + if (list.isEmpty()) status = "No valid plans found in file." + else { vm.importPlans(list); showImport = false; status = "Imported ${list.size} plan(s)." } + } + } + } + } + + var showNew by remember { mutableStateOf(false) } + var newName by remember { mutableStateOf("") } + + var showRenamePlan by remember { mutableStateOf(null) } + var renameVal by remember { mutableStateOf("") } + + val lazyListState = rememberLazyListState() + var reorderPersistJob by remember { mutableStateOf(null) } + val reorderState = rememberReorderableLazyListState(lazyListState) { from, to -> + // Offsets before plan items: + // 1) PageHeader item + // 2) Header-actions/import/status item + val offset = 2 + val fromIdx = from.index - offset + val toIdx = to.index - offset + if (fromIdx in orderedIds.indices && toIdx in orderedIds.indices) { + orderedIds = orderedIds.toMutableList().apply { add(toIdx, removeAt(fromIdx)) } + reorderPersistJob?.cancel() + reorderPersistJob = scope.launch { + delay(220) + vm.reorderPlans(orderedIds) + } + } + } + + Column(Modifier.fillMaxSize().background(c.bg).statusBarsPadding()) { + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // FIXED: 6 — PageHeader for tab screen + item { + PageHeader( + eyebrow = "TRAINING", + title = "Plans", + subtitle = "${plans.size} program${if (plans.size == 1) "" else "s"}", + ) + } + // Header actions + item { + Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(bottom = 4.dp)) { + ActionTile("BROWSE PROGRAMS", Icons.Outlined.LibraryBooks, c, onOpenProgramPicker) + ActionTile("IMPORT PLAN", Icons.Outlined.Download, c) { showImport = !showImport } + ActionTile("CREATE WITH AI", Icons.Outlined.AutoAwesome, c, onOpenAIPlan) + ActionTile("SCAN QR CODE", Icons.Outlined.QrCodeScanner, c, onOpenQrScan) + } + + if (showImport) { + Spacer(Modifier.height(10.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { planImportPicker.launch(arrayOf("application/json", "text/plain", "*/*")) }, + ) { Text("PICK JSON FILE") } + } + + if (status.isNotBlank()) { + Text(status, color = c.accent, modifier = Modifier.padding(vertical = 8.dp)) + } + } + + if (plans.isEmpty()) { + item { + Column( + Modifier.fillMaxWidth().padding(40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(Icons.Outlined.List, null, tint = c.muted, modifier = Modifier.size(24.dp)) + Text("No plans yet", color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.section.fontSize.sp) + Text("Pick a template or build your own routine.", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + Spacer(Modifier.height(8.dp)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(c.accentSoft) + .border(1.dp, c.accentBorder, RoundedCornerShape(10.dp)) + .clickable(onClick = onOpenProgramPicker) + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { Text("BROWSE TEMPLATES", color = c.accent, fontWeight = FontWeight.Bold, fontSize = IronLogType.body.fontSize.sp) } + } + } + } + + items(plans, key = { it.id }) { plan -> + ReorderableItem(reorderState, key = plan.id) { isDragging -> + PlanCard( + plan = plan, + history = history, + isDragging = isDragging, + onOpen = { onOpenPlan(plan.id) }, + onRename = { showRenamePlan = plan; renameVal = plan.name }, + onDelete = { planToDelete = plan }, + onDuplicate = { vm.duplicatePlan(plan) }, + onSetActive = { vm.setActivePlan(plan.id) }, + onShare = { + scope.launch(Dispatchers.IO) { + val json = uiPlanToJson(plan).toString(2) + val safeName = plan.name.replace(Regex("[^a-zA-Z0-9_\\-]"), "_") + val dir = java.io.File(context.filesDir, "plan_exports").also { it.mkdirs() } + val file = java.io.File(dir, "$safeName.json").also { it.writeText(json) } + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + withContext(Dispatchers.Main) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/json" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "${plan.name}.json")) + } + } + }, + onShareQr = { qrTargetPlan = plan }, + onStart = { dayId -> onStartWorkout(plan.id, dayId) }, + dragModifier = Modifier.draggableHandle(), + ) + } + } + + // Footer + item { + DashedNewPlanButton(c) { showNew = true } + } + } + } + + // Delete Plan Confirmation Dialog + planToDelete?.let { plan -> + AlertDialog( + onDismissRequest = { planToDelete = null }, + title = { Text("Delete \"${plan.name}\"?", color = c.text) }, + text = { Text("This will permanently delete the plan and all its days. This cannot be undone.", color = c.subtext) }, + confirmButton = { + TextButton(onClick = { + vm.removePlan(plan.id) + planToDelete = null + }) { + Text("DELETE", color = c.danger, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = { planToDelete = null }) { + Text("CANCEL", color = c.muted) + } + }, + containerColor = c.card, + ) + } + + // New Plan Modal + if (showNew) { + Dialog(onDismissRequest = { showNew = false }) { + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.8f)).padding(24.dp), contentAlignment = Alignment.Center) { + Card(colors = CardDefaults.cardColors(containerColor = c.surface), shape = RoundedCornerShape(20.dp), border = BorderStroke(1.dp, c.cardBorder)) { + Column(Modifier.padding(24.dp)) { + Text("NEW PLAN", color = c.text, fontWeight = FontWeight.Black, fontSize = IronLogType.body.fontSize.sp, letterSpacing = 3.sp, modifier = Modifier.padding(bottom = 16.dp)) + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + placeholder = { Text("Plan name", color = c.muted) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 20.dp)) { + Box(Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).border(1.dp, c.faint, RoundedCornerShape(14.dp)).clickable { showNew = false }.padding(14.dp), contentAlignment = Alignment.Center) { + Text("Cancel", color = c.muted) + } + Box(Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).background(c.accent).clickable { + if (newName.isNotBlank()) { vm.addPlan(newName.trim()); newName = ""; showNew = false } + }.padding(14.dp), contentAlignment = Alignment.Center) { + Text("CREATE", color = c.textOnAccent, fontWeight = FontWeight.Black) + } + } + } + } + } + } + } + + // QR Share Sheet + val qrPlan = qrTargetPlan + if (qrPlan != null) { + PlanQrShareSheet( + plan = qrPlan, + onDismiss = { qrTargetPlan = null }, + onShareText = { payload -> + val sendIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_TEXT, "ironlog://import?plan=$payload") + type = "text/plain" + } + context.startActivity(Intent.createChooser(sendIntent, "Share Plan")) + }, + ) + } + + // Rename Modal + val planToRename = showRenamePlan + if (planToRename != null) { + Dialog(onDismissRequest = { showRenamePlan = null }) { + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.8f)).padding(24.dp), contentAlignment = Alignment.Center) { + Card(colors = CardDefaults.cardColors(containerColor = c.surface), shape = RoundedCornerShape(20.dp), border = BorderStroke(1.dp, c.cardBorder)) { + Column(Modifier.padding(24.dp)) { + Text("RENAME PLAN", color = c.text, fontWeight = FontWeight.Black, fontSize = IronLogType.body.fontSize.sp, letterSpacing = 3.sp, modifier = Modifier.padding(bottom = 16.dp)) + OutlinedTextField( + value = renameVal, + onValueChange = { renameVal = it }, + placeholder = { Text("Plan name", color = c.muted) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 20.dp)) { + Box(Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).border(1.dp, c.faint, RoundedCornerShape(14.dp)).clickable { showRenamePlan = null }.padding(14.dp), contentAlignment = Alignment.Center) { + Text("Cancel", color = c.muted) + } + Box(Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).background(c.accent).clickable { + if (renameVal.isNotBlank()) { vm.renamePlan(planToRename.id, renameVal.trim()); showRenamePlan = null } + }.padding(14.dp), contentAlignment = Alignment.Center) { + Text("SAVE", color = c.textOnAccent, fontWeight = FontWeight.Black) + } + } + } + } + } + } + } +} + +@Composable +private fun PlanCard( + plan: UiPlan, + history: List = emptyList(), + isDragging: Boolean, + onOpen: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, + onShare: () -> Unit, + onShareQr: () -> Unit = {}, + onDuplicate: () -> Unit = {}, + onSetActive: () -> Unit = {}, + onStart: (dayId: String) -> Unit = {}, + dragModifier: Modifier = Modifier, +) { + val c = useTheme() + var expandedMenu by remember { mutableStateOf(false) } + var showDayPicker by remember { mutableStateOf(false) } + val exCount = plan.days.sumOf { it.exercises.count { !it.isWarmup } } + // GAP-19: plan session stats — count sessions matching any day in this plan + val planDayIds = remember(plan.days) { plan.days.map { it.id }.toSet() } + val planSessions = remember(history, planDayIds) { + history.filter { it.planDayUid != null && it.planDayUid in planDayIds } + } + val sessionCount = planSessions.size + val lastUsedDaysAgo = remember(planSessions) { + if (planSessions.isEmpty()) null + else { + val latestMs = planSessions.maxOfOrNull { + com.ironlog.app.domain.gamification.parseHistoryInstant(it.date)?.toEpochMilli() ?: return@remember null + } ?: return@remember null + val diffDays = ((System.currentTimeMillis() - latestMs) / (1000L * 60 * 60 * 24)).toInt() + diffDays + } + } + + // Static diagonal gradient — no animation on Plans to keep the list smooth + val cardGradient = Brush.linearGradient( + colors = if (isDragging) + listOf(c.accent.copy(alpha = 0.36f), c.accent.copy(alpha = 0.10f)) + else + listOf(c.accent.copy(alpha = 0.22f), c.accent.copy(alpha = 0.03f)), + start = Offset(0f, 0f), + end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY), + ) + + // FIXED: 35 — Active plan gets accent border + ACTIVE badge + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(c.card) + .background(cardGradient) + .border( + width = if (plan.isActive) 2.dp else 1.dp, + color = if (isDragging) c.accent.copy(alpha = 0.55f) else if (plan.isActive) c.accent.copy(alpha = 0.50f) else c.accent.copy(alpha = 0.20f), + shape = RoundedCornerShape(20.dp), + ), + ) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + // Header: play icon + plan name + menu/drag + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + // Play icon button + Box( + Modifier + .size(44.dp) + .clip(RoundedCornerShape(12.dp)) + .background(c.accent.copy(alpha = 0.16f)) + .border(1.dp, c.accent.copy(alpha = 0.24f), RoundedCornerShape(12.dp)) + .clickable(onClick = onOpen), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.PlayArrow, null, tint = c.accent, modifier = Modifier.size(22.dp)) + } + // Plan info — fills remaining space + Column(Modifier.weight(1f).clickable(onClick = onOpen)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(plan.name, color = c.text, fontWeight = FontWeight.Black, fontSize = IronLogType.section.fontSize.sp, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + if (plan.isActive) { + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.accent.copy(alpha = 0.15f)) + .border(1.dp, c.accent.copy(alpha = 0.4f), RoundedCornerShape(IronLogRadius.full.dp)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text("ACTIVE", color = c.accent, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight(IronLogType.button.fontWeight), letterSpacing = IronLogType.micro.letterSpacing.sp) + } + } + } + Text( + "${plan.days.size} days · $exCount exercises", + color = c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier.padding(top = 2.dp), + ) + // GAP-19: session stats sub-label + val statsLabel = if (sessionCount == 0) { + "No sessions yet" + } else { + val lastLabel = when { + lastUsedDaysAgo == null -> "" + lastUsedDaysAgo == 0 -> " · last used today" + lastUsedDaysAgo == 1 -> " · last used yesterday" + else -> " · last used $lastUsedDaysAgo days ago" + } + "$sessionCount session${if (sessionCount == 1) "" else "s"}$lastLabel" + } + Text( + statsLabel, + color = c.muted, + fontSize = IronLogType.micro.fontSize.sp, + modifier = Modifier.padding(top = 1.dp), + ) + } + // 3-dot menu + Box { + Box( + Modifier + .size(44.dp) + .clip(RoundedCornerShape(8.dp)) + .background(c.accent.copy(alpha = 0.10f)) + .clickable { expandedMenu = true }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.MoreVert, contentDescription = "Menu", tint = c.accent.copy(alpha = 0.7f), modifier = Modifier.size(16.dp)) + } + DropdownMenu(expandedMenu, { expandedMenu = false }, modifier = Modifier.background(c.surface)) { + DropdownMenuItem(text = { Text("Edit Plan", color = c.text) }, onClick = { expandedMenu = false; onOpen() }) + DropdownMenuItem(text = { Text("Rename", color = c.text) }, onClick = { expandedMenu = false; onRename() }) + if (!plan.isActive) { + DropdownMenuItem(text = { Text("Set as Active", color = c.text) }, onClick = { expandedMenu = false; onSetActive() }) + } + DropdownMenuItem(text = { Text("Duplicate", color = c.text) }, onClick = { expandedMenu = false; onDuplicate() }) + DropdownMenuItem(text = { Text("Share Plan", color = c.text) }, onClick = { expandedMenu = false; onShare() }) + DropdownMenuItem(text = { Text("Share as QR", color = c.text) }, onClick = { expandedMenu = false; onShareQr() }) + DropdownMenuItem(text = { Text("Delete", color = c.danger) }, onClick = { expandedMenu = false; onDelete() }) + } + } + // Drag handle + Box(dragModifier.size(44.dp), contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Menu, contentDescription = "Reorder", tint = c.accent.copy(alpha = 0.35f), modifier = Modifier.size(18.dp)) + } + } + + // Colored day chips + @OptIn(ExperimentalLayoutApi::class) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + plan.days.forEach { d -> + val safeColorStr = if (d.color.startsWith("#") && d.color.length == 7) d.color else "#E53935" + val parsedColor = Color(android.graphics.Color.parseColor(safeColorStr)) + Box( + Modifier + .clip(RoundedCornerShape(999.dp)) + .background(parsedColor.copy(alpha = 0.16f)) + .border(1.dp, parsedColor.copy(alpha = 0.30f), RoundedCornerShape(999.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Text(d.name, color = parsedColor, fontWeight = FontWeight.Bold, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 1.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + + // START button + if (plan.days.size == 1) { + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(c.accent) + .clickable { onStart(plan.days.first().id) } + .padding(vertical = 11.dp), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(Icons.Outlined.PlayArrow, null, tint = c.textOnAccent, modifier = Modifier.size(14.dp)) + Text("START WORKOUT", color = c.textOnAccent, fontWeight = FontWeight.ExtraBold, fontSize = IronLogType.meta.fontSize.sp, letterSpacing = 1.sp) + } + } + } else if (plan.days.isNotEmpty()) { + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (showDayPicker) c.accent.copy(alpha = 0.85f) else c.accent) + .clickable { showDayPicker = !showDayPicker } + .padding(vertical = 11.dp), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(Icons.Outlined.PlayArrow, null, tint = c.textOnAccent, modifier = Modifier.size(14.dp)) + Text( + if (showDayPicker) "CHOOSE SESSION ▲" else "START WORKOUT ▼", + color = c.textOnAccent, + fontWeight = FontWeight.ExtraBold, + fontSize = IronLogType.meta.fontSize.sp, + letterSpacing = 1.sp, + ) + } + } + if (showDayPicker) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + plan.days.forEach { day -> + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(c.accent.copy(alpha = 0.08f)) + .border(1.dp, c.accent.copy(alpha = 0.16f), RoundedCornerShape(10.dp)) + .clickable { onStart(day.id); showDayPicker = false } + .padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Outlined.PlayArrow, null, tint = c.accent, modifier = Modifier.size(13.dp)) + Text(day.name, color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.body.fontSize.sp) + } + Text("${day.exercises.count { !it.isWarmup }} exercises", color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + } + } + } + } +} + +@Composable +private fun ActionTile( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + c: IronLogThemeTokens, + onClick: () -> Unit, +) { + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(c.card) + .border(1.dp, c.cardBorder, RoundedCornerShape(18.dp)) + .clickable(onClick = onClick) + .padding(14.dp), + contentAlignment = Alignment.Center + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(icon, contentDescription = null, tint = if (label == "CREATE WITH AI") c.text else c.accent, modifier = Modifier.size(18.dp)) + Text(label, color = if (label == "CREATE WITH AI") c.text else c.accent, fontWeight = FontWeight.Black, fontSize = IronLogType.meta.fontSize.sp, letterSpacing = 2.sp) + } + } +} + +@Composable +private fun DashedNewPlanButton(c: IronLogThemeTokens, onClick: () -> Unit) { + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(c.accentSoft) + .clickable(onClick = onClick) + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + // Draw dashed border natively using Modifier.drawBehind if wanted, or just standard border + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Outlined.Add, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp)) + Text("NEW PLAN", color = c.accent, fontWeight = FontWeight.Bold, fontSize = IronLogType.meta.fontSize.sp, letterSpacing = 2.sp) + } + } +} + +private fun uiPlanToJson(plan: UiPlan): JSONObject { + val days = JSONArray() + plan.days.forEach { d -> + val exercises = JSONArray() + d.exercises.forEach { ex -> + exercises.put( + JSONObject() + .put("exerciseId", ex.exerciseId) + .put("name", ex.name) + .put("sets", ex.sets) + .put("reps", ex.reps) + .put("restSeconds", ex.restSeconds) + .put("supersetGroup", ex.supersetGroup) + .put("isWarmup", ex.isWarmup) + .put("notes", ex.notes), + ) + } + days.put(JSONObject().put("name", d.name).put("color", d.color).put("exercises", exercises)) + } + return JSONObject().put("name", plan.name).put("goal", plan.goal).put("description", plan.description).put("days", days) +} + +private fun parsePlansJson(raw: String): List { + val text = raw.trim() + if (text.isBlank()) return emptyList() + val arr = if (text.startsWith("[")) JSONArray(text) else JSONArray().put(JSONObject(text)) + val out = mutableListOf() + for (i in 0 until arr.length()) { + val p = arr.optJSONObject(i) ?: continue + val name = p.optString("name").takeIf { it.isNotBlank() } ?: continue + val goal = p.optString("goal").ifBlank { "General Fitness" } + val description = p.optString("description") + val daysJson = p.optJSONArray("days") ?: JSONArray() + val days = mutableListOf() + for (di in 0 until daysJson.length()) { + val d = daysJson.optJSONObject(di) ?: continue + val exJson = d.optJSONArray("exercises") ?: JSONArray() + val exercises = mutableListOf() + for (ei in 0 until exJson.length()) { + val e = exJson.optJSONObject(ei) ?: continue + exercises += PlanExerciseInput( + exerciseId = e.optString("exerciseId").takeIf { it.isNotBlank() }, + name = e.optString("name").takeIf { it.isNotBlank() }, + sets = e.optInt("sets", 3), + reps = e.optString("reps").ifBlank { "8-12" }, + restSeconds = e.optInt("restSeconds", 90), + supersetGroup = e.optString("supersetGroup"), + isWarmup = false, + notes = e.optString("notes"), + ) + } + days += FullPlanDay(name = d.optString("name").ifBlank { "Day ${di + 1}" }, color = d.optString("color").ifBlank { "#FF4500" }, exercises = exercises) + } + out += FullPlanObject(name = name, goal = goal, description = description, days = days) + } + return out +} + +fun matchExercise(name: String?, exerciseIndex: List): String? { + if (name.isNullOrBlank()) return null + val lower = name.lowercase() + val exact = exerciseIndex.firstOrNull { it.name.lowercase() == lower } + if (exact != null) return exact.id + val partial = exerciseIndex.firstOrNull { it.name.lowercase().contains(lower) || lower.contains(it.name.lowercase()) } + return partial?.id +} + + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/plans/ProgramInsightsScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/plans/ProgramInsightsScreen.kt new file mode 100644 index 0000000..413dd3b --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/plans/ProgramInsightsScreen.kt @@ -0,0 +1,270 @@ +package com.ironlog.app.ui.screens.plans + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.viewmodel.AppDataViewModel +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.WeekFields +import kotlin.math.max +import kotlin.math.roundToInt + +@Composable +fun ProgramInsightsScreen( + vm: AppDataViewModel = viewModel(), + onBack: () -> Unit = {}, + onOpenPlans: () -> Unit = {}, +) { + val c = useTheme() + val state by vm.state.collectAsState() + + // GAP-02: use the active plan, not just firstOrNull() + val activePlan = state.plans.firstOrNull { it.isActive } ?: state.plans.firstOrNull() + val workouts = state.history + val weeklyGoalDays = state.settings.weeklyGoalDays.coerceAtLeast(1) + + // GAP-01: real sessions-per-week from date range + val (sessionsPerWeek, adherence, consistency, weekCount) = remember(workouts, weeklyGoalDays) { + computeInsightsMetrics(workouts, weeklyGoalDays) + } + + // GAP-17: per-day adherence breakdown for active plan + val perDayStats = remember(workouts, activePlan) { + if (activePlan == null) emptyList() + else computePerDayAdherence(workouts, activePlan.days.map { it.name }) + } + + // PRs set since plan creation (approximate: since earliest workout) + val prSincePlan = remember(workouts) { + if (workouts.isEmpty()) emptyList>() + else { + val cutoff = workouts.minByOrNull { it.date }?.date ?: return@remember emptyList() + workouts.filter { it.date >= cutoff } + .flatMap { it.exercises } + .groupBy { it.name } + .mapValues { (_, exList) -> + exList.flatMap { it.sets } + .filter { it.type != "warmup" && it.weight > 0 } + .maxByOrNull { it.weight }?.weight + } + .filterValues { it != null } + .toList() + .sortedByDescending { it.second } + .take(5) + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize().background(c.bg).statusBarsPadding().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { ScreenHeader(title = "PROGRAM INSIGHTS", onBack = onBack) } + + // ── Current Program ──────────────────────────────────────────────── + item { + InsightsCard("Current Program") { + Text(activePlan?.name ?: "No active program selected.", color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (activePlan != null) { + Text("${activePlan.days.size} training days", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } else { + Button(onClick = onOpenPlans, modifier = Modifier.padding(top = 8.dp)) { Text("GO TO PLANS") } + } + } + } + + // ── Adherence ───────────────────────────────────────────────────── + item { + InsightsCard("Adherence") { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("$adherence%", color = c.accent, fontSize = IronLogType.title.fontSize.sp, fontWeight = FontWeight.Black) + Text("$weeklyGoalDays days/wk goal", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + LinearProgressIndicator( + progress = { (adherence / 100f).coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(IronLogRadius.full.dp)), + color = when { + adherence >= 85 -> c.success + adherence >= 50 -> c.warning + else -> c.danger + }, + trackColor = c.faint, + ) + Text( + "Based on $weekCount week${if (weekCount != 1) "s" else ""} of data · %.1f sessions/wk average".format(sessionsPerWeek), + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } + + // ── Consistency ─────────────────────────────────────────────────── + item { + InsightsCard("Consistency") { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("$consistency%", color = c.accent, fontSize = IronLogType.title.fontSize.sp, fontWeight = FontWeight.Black) + Text("of weeks hit goal", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + LinearProgressIndicator( + progress = { (consistency / 100f).coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(IronLogRadius.full.dp)), + color = if (consistency >= 70) c.success else c.warning, + trackColor = c.faint, + ) + Text( + "%.1f sessions/wk average over $weekCount week${if (weekCount != 1) "s" else ""}".format(sessionsPerWeek), + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } + + // ── Per-day adherence ───────────────────────────────────────────── + if (perDayStats.isNotEmpty()) { + item { + InsightsCard("Sessions by Day") { + perDayStats.forEach { (dayName, count) -> + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(dayName, color = c.text, fontSize = IronLogType.body.fontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + repeat(count.coerceAtMost(8)) { + Box(Modifier.size(8.dp).background(c.accent, CircleShape)) + } + Text("×$count", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + } + } + } + + // ── Top PRs ─────────────────────────────────────────────────────── + if (prSincePlan.isNotEmpty()) { + item { + InsightsCard("Top Lifts (All Time)") { + prSincePlan.forEach { (name, weight) -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(name, color = c.text, fontSize = IronLogType.body.fontSize.sp, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${weight?.let { "%.1f".format(it) }} kg", color = c.accent, fontSize = IronLogType.body.fontSize.sp, fontWeight = FontWeight.Bold) + } + } + } + } + } + + // ── Recommendation ──────────────────────────────────────────────── + item { + InsightsCard("Recommendation") { + val recommendation = when { + activePlan == null -> "Create or import a plan to receive progression guidance." + adherence < 50 -> "Adherence is low. Reduce planned days or shorten sessions to build the habit first." + adherence < 85 -> "Stay steady. Focus on showing up consistently before adding volume." + consistency >= 80 -> "Excellent consistency! Consider a progressive overload block or deload week." + else -> "Good work. Increase one key lift by 2.5–5% next week and track the response." + } + Text(recommendation, color = c.subtext, lineHeight = IronLogType.body.lineHeight.sp) + } + } + } +} + +// ── Metric computation ──────────────────────────────────────────────────────── + +private data class InsightsMetrics( + val sessionsPerWeek: Double, + val adherencePct: Int, + val consistencyPct: Int, + val weekCount: Int, +) + +private fun computeInsightsMetrics(workouts: List, weeklyGoalDays: Int): InsightsMetrics { + if (workouts.isEmpty()) return InsightsMetrics(0.0, 0, 0, 0) + val zone = ZoneId.systemDefault() + val weekFields = WeekFields.ISO + // Group sessions by ISO week key + val byWeek = workouts.groupBy { entry -> + // Use tolerant two-step parse so bare YYYY-MM-DD dates (e.g. from widget + // or imported data) don't throw an unchecked DateTimeParseException and + // crash the entire composable. + val d = runCatching { Instant.parse(entry.date).atZone(zone).toLocalDate() }.getOrNull() + ?: runCatching { java.time.LocalDate.parse(entry.date.substringBefore('T')) }.getOrNull() + ?: return@groupBy "unknown" + "${d.get(weekFields.weekBasedYear())}-W${d.get(weekFields.weekOfWeekBasedYear()).toString().padStart(2, '0')}" + }.filter { (k, _) -> k != "unknown" } + val weekCount = max(1, byWeek.size) + val totalSessions = workouts.size + val sessionsPerWeek = totalSessions.toDouble() / weekCount + val adherence = ((sessionsPerWeek / weeklyGoalDays) * 100.0).toInt().coerceIn(0, 100) + // Consistency: % of weeks where user hit their goal + val weeksHit = byWeek.values.count { it.size >= weeklyGoalDays } + val consistency = ((weeksHit.toDouble() / weekCount) * 100.0).roundToInt().coerceIn(0, 100) + return InsightsMetrics(sessionsPerWeek, adherence, consistency, weekCount) +} + +private fun computePerDayAdherence(workouts: List, planDayNames: List): List> { + // Count how many times each plan day name appears in workout names + return planDayNames.map { dayName -> + val count = workouts.count { it.name.contains(dayName, ignoreCase = true) || it.dayName?.contains(dayName, ignoreCase = true) == true } + dayName to count + }.filter { it.first.isNotBlank() } +} + +// ── Card composable ─────────────────────────────────────────────────────────── + +@Composable +private fun InsightsCard(title: String, content: @Composable () -> Unit) { + val c = useTheme() + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + ) { + Column( + Modifier.fillMaxWidth().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(title, color = c.text, fontSize = IronLogType.section.fontSize.sp, fontWeight = FontWeight.SemiBold) + content() + } + } +} + + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/plans/ProgramPickerScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/plans/ProgramPickerScreen.kt new file mode 100644 index 0000000..55dc615 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/plans/ProgramPickerScreen.kt @@ -0,0 +1,304 @@ +package com.ironlog.app.ui.screens.plans + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.data.repository.PlanRepository +import com.ironlog.app.data.seed.PROGRAM_TEMPLATES +import com.ironlog.app.data.seed.ProgramTemplate +import com.ironlog.app.data.seed.toPlanObject +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.theme.IronLogRadius +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun ProgramPickerScreen( + planRepo: PlanRepository = PlanRepository(), + onBack: () -> Unit = {}, +) { + val c = useTheme() + val scope = rememberCoroutineScope() + var category by remember { mutableStateOf("All") } + var selected by remember { mutableStateOf(null) } + var status by remember { mutableStateOf(null) } + var searchQuery by remember { mutableStateOf("") } + + val categories = remember { + listOf("All") + PROGRAM_TEMPLATES.map { it.category }.distinct() + } + val filtered = remember(category, searchQuery) { + val byCat = if (category == "All") PROGRAM_TEMPLATES else PROGRAM_TEMPLATES.filter { it.category == category } + if (searchQuery.isBlank()) byCat + else { + val q = searchQuery.trim().lowercase() + byCat.filter { it.name.lowercase().contains(q) || it.description.lowercase().contains(q) || it.category.lowercase().contains(q) } + } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(c.bg) + .statusBarsPadding(), + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(bottom = 80.dp), + ) { + // ── Header ──────────────────────────────────────────────────────────── + item { ScreenHeader(title = "PROGRAMS", onBack = onBack) } + item { + Column(Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { + Text( + "Programs", + color = c.text, + fontSize = IronLogType.title.fontSize.sp, + fontWeight = FontWeight.Black, + ) + Spacer(Modifier.height(4.dp)) + Text( + "Choose a built-in program by category, or build your own from scratch.", + color = c.subtext, + fontSize = IronLogType.body.fontSize.sp, + ) + } + } + + // ── Horizontally scrollable category filter chips ───────────────────── + item { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + items(categories) { cat -> + val active = category == cat + Box( + modifier = Modifier + .clip(CircleShape) + .background(if (active) c.accent.copy(alpha = 0.18f) else c.surface) + .border(1.5.dp, if (active) c.accent else c.cardBorder, CircleShape) + .clickable { category = cat } + .padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + cat.uppercase(), + color = if (active) c.accent else c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = if (active) FontWeight.ExtraBold else FontWeight.SemiBold, + letterSpacing = 0.8.sp, + ) + } + } + } + } + + // ── Search bar ──────────────────────────────────────────────────────── + item { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("Search programs…", color = c.muted, fontSize = IronLogType.body.fontSize.sp) }, + leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null, tint = c.muted, modifier = Modifier.size(18.dp)) }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Outlined.Close, contentDescription = "Clear", tint = c.muted, modifier = Modifier.size(18.dp)) + } + } + }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + singleLine = true, + ) + } + + // ── Program cards ────────────────────────────────────────────────────── + if (filtered.isEmpty()) { + item { + Box(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 24.dp), contentAlignment = Alignment.Center) { + Text( + "No programs match \"${searchQuery.trim()}\"", + color = c.muted, + fontSize = IronLogType.body.fontSize.sp, + ) + } + } + } else { + items(filtered, key = { it.id }) { p -> + ProgramCard( + template = p, + onClick = { selected = p }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + + // ── Status message ──────────────────────────────────────────────────── + status?.let { msg -> + item { + Text( + msg, + color = if (msg.startsWith("Failed")) c.danger else c.success, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + } + + // ── Program detail bottom sheet ─────────────────────────────────────────── + selected?.let { tpl -> + ModalBottomSheet( + onDismissRequest = { selected = null }, + containerColor = c.card, + contentColor = c.text, + ) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Badge + name + Box( + Modifier + .clip(CircleShape) + .background(c.accentSoft) + .border(1.dp, c.accentBorder, CircleShape) + .padding(horizontal = 12.dp, vertical = 5.dp), + ) { + Text("${tpl.days.size}x / week", color = c.accent, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Bold) + } + Text(tpl.name, color = c.text, fontSize = IronLogType.title.fontSize.sp, fontWeight = FontWeight.Black) + Text(tpl.description, color = c.subtext, fontSize = IronLogType.body.fontSize.sp) + + HorizontalDivider(color = c.cardBorder) + + // Day list + tpl.days.forEach { d -> + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(d.name ?: "", color = c.text, fontSize = IronLogType.body.fontSize.sp, fontWeight = FontWeight.SemiBold) + Text("${d.exercises.size} exercises", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + } + + Spacer(Modifier.height(4.dp)) + + Button( + onClick = { + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { planRepo.importFullPlan(tpl.toPlanObject()) } + } + result + .onSuccess { status = "Added '${tpl.name}' to your plans."; selected = null } + .onFailure { status = "Failed: ${it.message ?: "unknown error"}" } + } + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = c.accent), + ) { + Text( + "ADD TO MY PLANS", + fontWeight = FontWeight.ExtraBold, + letterSpacing = 1.sp, + fontSize = IronLogType.body.fontSize.sp, + ) + } + } + } + } +} + +@Composable +private fun ProgramCard( + template: ProgramTemplate, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val c = useTheme() + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + shape = RoundedCornerShape(IronLogRadius.xl.dp), + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), + ) { + Column( + Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Badge row: frequency + difficulty + duration + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + @Composable + fun Badge(label: String) { + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.accentSoft) + .border(1.dp, c.accentBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { + Text(label, color = c.accent, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Bold) + } + } + Badge("${template.days.size}x/week") + Badge(template.difficulty) + Badge("${template.durationWeeks}w") + } + Text(template.name, color = c.text, fontSize = IronLogType.section.fontSize.sp, fontWeight = FontWeight.Black, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(template.description, color = c.subtext, fontSize = IronLogType.body.fontSize.sp, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/recovery/RecoveryCircuitSheet.kt b/app/src/main/java/com/ironlog/app/ui/screens/recovery/RecoveryCircuitSheet.kt new file mode 100644 index 0000000..159c895 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/recovery/RecoveryCircuitSheet.kt @@ -0,0 +1,154 @@ +package com.ironlog.app.ui.screens.recovery + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.foundation.BorderStroke +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +data class RecoveryCircuit( + val id: String, + val name: String, + val category: String, // "Push", "Pull", "Core", "Full Body" + val exercises: List, + val instructions: String, +) + +private val CIRCUITS = listOf( + RecoveryCircuit( + id = "push_basic", + name = "Push Circuit", + category = "Push", + exercises = listOf("20 Push-ups", "15 Tricep Dips (chair)", "10 Pike Push-ups"), + instructions = "3 rounds, 60 sec rest between rounds.", + ), + RecoveryCircuit( + id = "pull_basic", + name = "Pull Circuit", + category = "Pull", + exercises = listOf("10 Pull-ups (or 15 Inverted Rows)", "12 Chin-ups", "20 Band Pull-Aparts"), + instructions = "3 rounds, 90 sec rest between rounds.", + ), + RecoveryCircuit( + id = "core_basic", + name = "Core Circuit", + category = "Core", + exercises = listOf("30 Crunches", "20 Leg Raises", "60 sec Plank", "20 Russian Twists"), + instructions = "3 rounds, 45 sec rest between rounds.", + ), + RecoveryCircuit( + id = "fullbody_basic", + name = "Full Body Circuit", + category = "Full Body", + exercises = listOf("20 Burpees", "20 Squats", "15 Push-ups", "10 Pull-ups", "30 sec Plank"), + instructions = "4 rounds, 90 sec rest between rounds.", + ), +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecoveryCircuitSheet( + onDismiss: () -> Unit, + onComplete: (circuitId: String) -> Unit, +) { + var selected: RecoveryCircuit? by remember { mutableStateOf(null) } + var confirmed by remember { mutableStateOf(false) } + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "Recovery Circuit", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + "Complete one bodyweight circuit to protect this week's proof trail.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (!confirmed) { + CIRCUITS.forEach { circuit -> + val isSelected = selected?.id == circuit.id + OutlinedCard( + onClick = { selected = circuit }, + modifier = Modifier.fillMaxWidth(), + border = if (isSelected) + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + else + BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(circuit.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text(circuit.category, style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary) + } + Spacer(Modifier.height(4.dp)) + circuit.exercises.forEach { ex -> + Text("- $ex", style = MaterialTheme.typography.bodySmall) + } + } + } + } + + Button( + onClick = { if (selected != null) confirmed = true }, + enabled = selected != null, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Start Circuit") + } + } else { + val circuit = selected!! + Text("Complete this circuit:", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + circuit.exercises.forEach { ex -> + Text("- $ex", style = MaterialTheme.typography.bodyMedium) + } + Text(circuit.instructions, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { onComplete(circuit.id) }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary), + ) { + Text("Completed -- Save Proof (+25 XP)") + } + + TextButton(onClick = { confirmed = false }, modifier = Modifier.fillMaxWidth()) { + Text("Choose a different circuit") + } + } + } + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/recovery/RecoveryHeatmapCard.kt b/app/src/main/java/com/ironlog/app/ui/screens/recovery/RecoveryHeatmapCard.kt new file mode 100644 index 0000000..44b858a --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/recovery/RecoveryHeatmapCard.kt @@ -0,0 +1,263 @@ +package com.ironlog.app.ui.screens.recovery + +import com.ironlog.app.ui.screens.body.BodyHalfCanvas +import com.ironlog.app.ui.screens.body.BodyMapDataset +import com.ironlog.app.ui.screens.body.ViewBox +import com.ironlog.app.ui.screens.body.BodyPathPiece +import com.ironlog.app.ui.screens.body.loadBodyMapDataset +import com.ironlog.app.ui.screens.body.buildDisplayReadiness +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.domain.intelligence.ManualRecoveryInput +import com.ironlog.app.domain.intelligence.RecoveryReadinessEngine +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogType + +private val LEGEND = listOf( + "< 72%" to "danger", + "72–90%" to "warning", + "> 90%" to "success", + "Unknown" to "faint", +) + +/** + * Compact muscle-map card shown on the HomeScreen. + * Mirrors the RN RecoveryHeatmap component. + * + * @param groupReadiness Output of [RecoveryReadinessEngine.readinessByRegion] — + * keyed by group name (Push, Pull, Legs, …). + */ +@Composable +fun RecoveryHeatmapCard( + groupReadiness: Map, + manualRecoveryInput: ManualRecoveryInput? = null, + onTapExpand: () -> Unit, + onOpenVolumeAnalytics: () -> Unit, +) { + val c = useTheme() + val context = LocalContext.current + val bodyMapDataset = remember { loadBodyMapDataset(context) } + val displayReadiness = remember(groupReadiness) { buildDisplayReadiness(groupReadiness) } + val recoveryScore = remember(groupReadiness) { RecoveryReadinessEngine.score(groupReadiness) } + + // Fallback viewBox dimensions (actual values from the asset) + val frontVb = bodyMapDataset?.front?.viewBox ?: ViewBox(-20f, 95f, 740f, 1300f) + val backVb = bodyMapDataset?.back?.viewBox ?: ViewBox(740f, 95f, 680f, 1320f) + val frontAlignVb = bodyMapDataset?.front?.alignmentBox ?: frontVb + val backAlignVb = bodyMapDataset?.back?.alignmentBox ?: backVb + val fitWidth = maxOf(frontAlignVb.width, backAlignVb.width) + val fitHeight = maxOf(frontAlignVb.height, backAlignVb.height) + val sharedAspect = fitWidth / fitHeight + val cardMapHeight = 280.dp + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + shape = RoundedCornerShape(14.dp), + ) { + // ── Tappable body (header + maps + legend) ──────────────────────────── + Column( + Modifier + .fillMaxWidth() + .clickable(onClick = onTapExpand) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + // Header row + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "MUSCLE RECOVERY", + color = c.muted, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 3.sp, + ) + Text( + "TAP TO EXPAND →", + color = c.muted, + fontSize = IronLogType.micro.fontSize.sp, + letterSpacing = 0.5.sp, + ) + } + + Spacer(Modifier.height(10.dp)) + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { + Text( + "${recoveryScore.score}", + color = when { + recoveryScore.score >= 78 -> c.success + recoveryScore.score >= 55 -> c.warning + else -> c.danger + }, + fontSize = IronLogType.display.fontSize.sp, + fontWeight = FontWeight.Black, + ) + Text( + recoveryScore.state.replaceFirstChar { it.titlecase() }, + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.Medium, + ) + } + Spacer(Modifier.height(8.dp)) + + // Maps + legend row + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + // Front + Column( + Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("FRONT", color = c.muted, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.6.sp) + Spacer(Modifier.height(2.dp)) + BodyMapFixedSlot( + pieces = bodyMapDataset?.front?.pieces.orEmpty(), + alignmentBox = frontAlignVb, + readiness = displayReadiness, + fitWidth = fitWidth, + fitHeight = fitHeight, + aspect = sharedAspect, + modifier = Modifier.fillMaxWidth().height(cardMapHeight), + ) + } + // Back + Column( + Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("BACK", color = c.muted, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.6.sp) + Spacer(Modifier.height(2.dp)) + BodyMapFixedSlot( + pieces = bodyMapDataset?.back?.pieces.orEmpty(), + alignmentBox = backAlignVb, + readiness = displayReadiness, + fitWidth = fitWidth, + fitHeight = fitHeight, + aspect = sharedAspect, + modifier = Modifier.fillMaxWidth().height(cardMapHeight), + ) + } + } + Spacer(Modifier.height(14.dp)) + RecoveryLegendFlow() + } + + // ── Analytics button — styled like RN (accent border + tinted bg) ───── + HorizontalDivider(color = c.cardBorder) + Box( + modifier = Modifier + .fillMaxWidth() + .background(c.accent.copy(alpha = 0.10f)) + .clickable(onClick = onOpenVolumeAnalytics) + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "OPEN VOLUME ANALYTICS", + color = c.accent, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 1.3.sp, + ) + } + } +} + +@Composable +private fun BodyMapFixedSlot( + pieces: List, + alignmentBox: ViewBox, + readiness: Map, + fitWidth: Float, + fitHeight: Float, + aspect: Float, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier = modifier, + contentAlignment = Alignment.TopCenter, + ) { + val canvasH = minOf(maxHeight, maxWidth / aspect) + val canvasW = canvasH * aspect + BodyHalfCanvas( + pieces = pieces, + viewBox = alignmentBox, + readiness = readiness, + modifier = Modifier.size(canvasW, canvasH), + fitWidth = fitWidth, + fitHeight = fitHeight, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RecoveryLegendFlow() { + val c = useTheme() + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + LEGEND.forEach { (label, key) -> + val dotColor = when (key) { + "danger" -> c.danger + "warning" -> c.warning + "success" -> c.success + else -> c.faint + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box( + Modifier + .size(8.dp) + .clip(CircleShape) + .background(dotColor), + ) + Text( + label, + color = c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/recovery/RecoveryMapScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/recovery/RecoveryMapScreen.kt new file mode 100644 index 0000000..04fbf38 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/recovery/RecoveryMapScreen.kt @@ -0,0 +1,785 @@ +package com.ironlog.app.ui.screens.recovery + +import com.ironlog.app.ui.screens.body.BodyHalfCanvas +import com.ironlog.app.ui.screens.body.BodyMapDataset +import com.ironlog.app.ui.screens.body.ViewBox +import com.ironlog.app.ui.screens.body.BodyPathPiece +import com.ironlog.app.ui.screens.body.loadBodyMapDataset +import com.ironlog.app.ui.screens.body.buildDisplayReadiness +import com.ironlog.app.ui.screens.body.computeBodyTransform +import com.ironlog.app.ui.screens.body.VerticalAnchor +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.foundation.clickable +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import com.ironlog.app.ui.theme.IronLogRadius +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidPath +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ironlog.app.domain.intelligence.ManualRecoveryInput +import com.ironlog.app.domain.intelligence.RecoveryReadinessEngine +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.theme.IronLogThemeTokens +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.viewmodel.StatsViewModel +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import java.text.SimpleDateFormat +import java.time.Instant +import java.time.ZoneId +import java.util.Date +import java.util.Locale +import kotlin.math.ceil +import kotlin.math.floor +import kotlinx.coroutines.launch + +private val RECOVERY_WINDOWS = listOf("Workout" to 3, "7D" to 7, "30D" to 30, "Program" to 90) +private val LEGEND = listOf( + "Back Off (<72%)" to "danger", + "Maintain (72-90%)" to "warning", + "Train (>90%)" to "success", + "Unknown" to "faint", +) + +@Composable +fun RecoveryMapScreen( + vm: StatsViewModel = viewModel(), + onBack: () -> Unit = {}, + onOpenVolumeAnalytics: () -> Unit = {}, +) { + val c = useTheme() + val context = LocalContext.current + val state by vm.state.collectAsState() + val manualInput by vm.manualRecoveryInput.collectAsState() + val bodyMapDataset = remember { loadBodyMapDataset(context) } + var windowKey by remember { mutableStateOf("30D") } + val rangeDays = remember(windowKey) { RECOVERY_WINDOWS.firstOrNull { it.first == windowKey }?.second ?: 30 } + var selected by remember { mutableStateOf(null) } + var showManualModal by remember { mutableStateOf(false) } + val filtered = remember(state.history, rangeDays) { state.history.filter { recoveryAgeDays(it.date) <= rangeDays } } + + // GAP-20: Pain flags — loaded from SettingsRepository on entry; saved on toggle + val scope = rememberCoroutineScope() + val settingsRepo = remember { com.ironlog.app.data.repository.SettingsRepository() } + val painRegions = listOf("Push", "Pull", "Legs", "Core", "Arms", "Shoulders") + var painFlags by remember { mutableStateOf(setOf()) } + LaunchedEffect(Unit) { + val loaded = painRegions.filter { region -> + settingsRepo.getString("pain_flag_${region}") == "true" + }.toSet() + painFlags = loaded + } + + val readiness = remember(filtered, painFlags) { RecoveryReadinessEngine.readinessByRegion(filtered, painFlags) } + val displayReadiness = remember(readiness) { buildDisplayReadiness(readiness) } + val recoveryScore = remember(readiness, manualInput) { RecoveryReadinessEngine.score(readiness, manualInput) } + val score = recoveryScore.score + val suggestions = remember(readiness) { RecoveryReadinessEngine.suggestions(readiness) } + + // GAP-15: 14-day readiness trend — compute one score per day. + val trend14 = remember(state.history) { + val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + val now = System.currentTimeMillis() + (0 until 14).map { daysBack -> + val dayMs = now - daysBack * 86_400_000L + val dayKey = sdf.format(java.util.Date(dayMs)) + val dayHistory = state.history.filter { it.date.startsWith(dayKey) } + // Use sessions up to and including this day (last 7 days of that point) for readiness + val window = state.history.filter { + val t = com.ironlog.app.domain.gamification.parseHistoryInstant(it.date)?.toEpochMilli() + ?: return@filter false + t <= dayMs && t >= dayMs - 7 * 86_400_000L + } + val r = RecoveryReadinessEngine.readinessByRegion(window, nowEpochMs = dayMs) + val score = RecoveryReadinessEngine.score(r, nowEpochMs = dayMs).score + Pair(dayKey, score) + }.reversed() // oldest first + } + + val confidenceLabel = remember(filtered.size, manualInput) { + val base = when { + filtered.size >= 16 -> "High confidence" + filtered.size >= 8 -> "Moderate confidence" + filtered.size >= 3 -> "Low confidence" + else -> "Very low confidence" + } + if (manualInput != null && System.currentTimeMillis() - manualInput!!.recordedAt < 48 * 3600_000L) { + "$base + manual check-in" + } else base + } + + if (showManualModal) { + ManualRecoveryCheckInModal( + initial = manualInput ?: ManualRecoveryInput(), + painFlags = painFlags, + painRegions = painRegions, + onDismiss = { showManualModal = false }, + onSave = { updatedInput, updatedFlags -> + vm.viewModelScope.launch { + vm.saveManualRecovery(updatedInput) + showManualModal = false + } + // Persist pain flags + scope.launch { + painRegions.forEach { region -> + settingsRepo.setString("pain_flag_${region}", if (region in updatedFlags) "true" else "false") + } + painFlags = updatedFlags + } + }, + ) + } + + LazyColumn( + modifier = Modifier.fillMaxSize().background(c.bg).statusBarsPadding().padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 16.dp, bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + ScreenHeader(title = "Recovery Map", onBack = onBack) + Text("MUSCLE RECOVERY", color = c.accent, fontSize = IronLogType.eyebrow.fontSize.sp, fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), letterSpacing = IronLogType.eyebrow.letterSpacing.sp) + Text("Recovery", color = c.text, fontSize = IronLogType.title.fontSize.sp, fontWeight = FontWeight(IronLogType.display.fontWeight), lineHeight = IronLogType.title.lineHeight.sp) + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + RECOVERY_WINDOWS.forEach { (label, _) -> + val active = windowKey == label + Box( + modifier = Modifier + .clip(androidx.compose.foundation.shape.CircleShape) + .background(if (active) c.accent.copy(alpha = 0.18f) else c.surface) + .border(1.5.dp, if (active) c.accent else c.cardBorder, androidx.compose.foundation.shape.CircleShape) + .clickable { windowKey = label } + .padding(horizontal = 14.dp, vertical = 7.dp), + contentAlignment = Alignment.Center, + ) { + Text( + label, + color = if (active) c.accent else c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = if (active) FontWeight.ExtraBold else FontWeight.Bold, + ) + } + } + } + } + item { + Card(colors = CardDefaults.cardColors(containerColor = c.card), border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder)) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text("Overall readiness", color = c.muted) + Text("$score", color = if (score >= 70) c.success else if (score >= 40) c.warning else c.danger, fontSize = IronLogType.display.fontSize.sp) + // Action directive chip + val actionDirective = if (score >= 78) "Train" else if (score >= 55) "Maintain" else "Back Off" + val directiveColor = if (score >= 78) c.success else if (score >= 55) c.warning else c.danger + Box( + Modifier + .clip(CircleShape) + .background(directiveColor.copy(alpha = 0.15f)) + .border(1.dp, directiveColor.copy(alpha = 0.45f), CircleShape) + .padding(horizontal = 14.dp, vertical = 4.dp), + ) { + Text(actionDirective, color = directiveColor, fontWeight = FontWeight.ExtraBold, fontSize = IronLogType.meta.fontSize.sp, letterSpacing = 0.5.sp) + } + Text(confidenceLabel, color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + Text(recoveryScore.state.replaceFirstChar { it.titlecase() }, color = c.text, fontSize = IronLogType.section.fontSize.sp) + Text(recoveryScore.explanation, color = c.muted, fontSize = IronLogType.meta.fontSize.sp, textAlign = androidx.compose.ui.text.style.TextAlign.Center) + + Button( + onClick = { showManualModal = true }, + colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent, contentColor = c.accent), + border = androidx.compose.foundation.BorderStroke(1.dp, c.accent), + modifier = Modifier.padding(top = 8.dp) + ) { + Text("UPDATE MANUAL CHECK-IN", fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 1.2.sp) + } + + Text("Tap a region to view details.", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, modifier = Modifier.padding(vertical = 8.dp)) + + RecoveryBodyMap( + readiness = displayReadiness, + dataset = bodyMapDataset, + onSelect = { selected = it }, + painFlags = painFlags, + ) + } + } + } + // GAP-15: 14-day readiness trend chart + item { + Card(colors = CardDefaults.cardColors(containerColor = c.card), border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder)) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("READINESS TREND — 14 DAYS", color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp) + ReadinessTrendChart(trend14 = trend14, modifier = Modifier.fillMaxWidth().height(120.dp)) + // X-axis labels: first and last date only to avoid crowding + if (trend14.size >= 2) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(trend14.first().first.substring(5), color = c.muted, fontSize = IronLogType.micro.fontSize.sp) + Text(trend14.last().first.substring(5), color = c.muted, fontSize = IronLogType.micro.fontSize.sp) + } + } + } + } + } + + item { + Card(colors = CardDefaults.cardColors(containerColor = c.surface), border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder)) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Suggestions", color = c.text, fontSize = IronLogType.section.fontSize.sp) + if (suggestions.isEmpty()) { + Text("Not enough recent data to generate suggestions.", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } else { + suggestions.forEach { tip -> + Text("- $tip", color = c.subtext, fontSize = IronLogType.body.fontSize.sp) + } + } + } + } + } + item { + androidx.compose.material3.TextButton(onClick = onOpenVolumeAnalytics, modifier = Modifier.fillMaxWidth()) { + Text("OPEN VOLUME ANALYTICS", color = c.accent, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) + } + } + } + + selected?.let { region -> + RecoveryRegionSheet( + region = region, + readiness = displayReadiness[region] ?: 1.0, + filtered = filtered, + sourceLabel = confidenceLabel, + onDismiss = { selected = null }, + ) + } +} + +@Composable +private fun RecoveryBodyMap(readiness: Map, dataset: BodyMapDataset?, onSelect: (String) -> Unit, painFlags: Set = emptySet()) { + val c = useTheme() + val frontPieces = dataset?.front?.pieces.orEmpty() + val backPieces = dataset?.back?.pieces.orEmpty() + val pagerState = rememberPagerState(pageCount = { 2 }) + val scope = rememberCoroutineScope() + + val frontVb = dataset?.front?.alignmentBox ?: ViewBox(-20f, 95f, 740f, 1300f) + val backVb = dataset?.back?.alignmentBox ?: ViewBox(740f, 95f, 680f, 1320f) + val fitWidth = maxOf(frontVb.width, backVb.width) + val fitHeight = maxOf(frontVb.height, backVb.height) + val sharedAspect = fitWidth / fitHeight + val mapViewportHeight = 470.dp + + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + listOf("FRONT", "BACK").forEachIndexed { index, label -> + val active = pagerState.currentPage == index + Box( + Modifier + .clip(CircleShape) + .background(if (active) c.accent.copy(alpha = 0.16f) else c.surface) + .border(1.dp, if (active) c.accent.copy(alpha = 0.55f) else c.cardBorder, CircleShape) + .clickable { scope.launch { pagerState.animateScrollToPage(index) } } + .padding(horizontal = 18.dp, vertical = 7.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, color = if (active) c.accent else c.muted, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = 1.4.sp) + } + } + } + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .height(mapViewportHeight) + .clipToBounds(), + ) { page -> + val isFront = page == 0 + val activePieces = if (isFront) frontPieces else backPieces + val activeVb = if (isFront) frontVb else backVb + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .clipToBounds(), + contentAlignment = Alignment.TopCenter, + ) { + val pageLayout = computeRecoveryMapPageLayout( + maxWidthDp = maxWidth.value, + maxHeightDp = maxHeight.value, + aspect = sharedAspect, + ) + Box( + Modifier + .padding(top = pageLayout.topPaddingDp.dp) + .size(pageLayout.canvasWidthDp.dp, pageLayout.canvasHeightDp.dp), + contentAlignment = Alignment.Center, + ) { + BodyHalfCanvasInteractive( + pieces = activePieces, + viewBox = activeVb, + readiness = readiness, + modifier = Modifier.matchParentSize(), + fitWidth = fitWidth, + fitHeight = fitHeight, + onRegionTap = onSelect, + painFlags = painFlags, + ) + } + } + } + RecoveryLegendFlow() + } +} + +internal data class RecoveryBodyMapPageLayout( + val canvasWidthDp: Float, + val canvasHeightDp: Float, + val topPaddingDp: Float, +) + +internal fun computeRecoveryMapPageLayout( + maxWidthDp: Float, + maxHeightDp: Float, + aspect: Float, +): RecoveryBodyMapPageLayout { + val topPaddingDp = 12f + val availableHeightDp = (maxHeightDp - topPaddingDp).coerceAtLeast(1f) + val widthLimitedHeightDp = (maxWidthDp * 0.80f) / aspect.coerceAtLeast(0.01f) + val canvasHeightDp = minOf(availableHeightDp, widthLimitedHeightDp, 420f) + return RecoveryBodyMapPageLayout( + canvasWidthDp = canvasHeightDp * aspect, + canvasHeightDp = canvasHeightDp, + topPaddingDp = topPaddingDp, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RecoveryLegendFlow() { + val c = useTheme() + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + LEGEND.forEach { (label, key) -> + val dotColor = when (key) { + "danger" -> c.danger + "warning" -> c.warning + "success" -> c.success + else -> c.faint + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Box( + Modifier + .size(8.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(dotColor), + ) + Text(label, color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + } + } + } +} + +@Composable +private fun RecoveryLegendDot(label: String, color: Color) { + val c = useTheme() + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Box( + Modifier + .size(8.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(color), + ) + Text(label, color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + } +} + +@Composable +private fun BodyHalfCanvasInteractive( + pieces: List, + viewBox: ViewBox, + readiness: Map, + modifier: Modifier, + fitWidth: Float = viewBox.width, + fitHeight: Float = viewBox.height, + onRegionTap: (String) -> Unit, + painFlags: Set = emptySet(), // GAP-20 +) { + var canvasSize by remember { mutableStateOf(Size.Zero) } + + Box(modifier = modifier.pointerInput(pieces, viewBox, canvasSize) { + detectTapGestures { offset -> + if (canvasSize == Size.Zero) return@detectTapGestures + // Use the same uniform-scale + centred transform as BodyHalfCanvas + val t = computeBodyTransform( + canvasSize.width, + canvasSize.height, + viewBox, + fitWidth = fitWidth, + fitHeight = fitHeight, + verticalAnchor = VerticalAnchor.TOP, + ) + val translationX = t.offsetX - viewBox.x * t.scale + val translationY = t.offsetY - viewBox.y * t.scale + val svgX = (offset.x - translationX) / t.scale + val svgY = (offset.y - translationY) / t.scale + var bestHitRegion: String? = null + var bestHitArea = Float.MAX_VALUE + for (piece in pieces) { + if (piece.region.isBlank()) continue + val androidPath = piece.path.asAndroidPath() + val r = android.graphics.RectF() + androidPath.computeBounds(r, true) + if (r.isEmpty) continue + val region = android.graphics.Region() + region.setPath( + androidPath, + android.graphics.Region( + floor(r.left).toInt(), + floor(r.top).toInt(), + ceil(r.right).toInt(), + ceil(r.bottom).toInt(), + ), + ) + if (region.contains(svgX.toInt(), svgY.toInt())) { + val area = r.width() * r.height() + if (area < bestHitArea) { + bestHitArea = area + bestHitRegion = piece.region + } + } + } + bestHitRegion?.let(onRegionTap) + } + }) { + BodyHalfCanvas( + pieces = pieces, + viewBox = viewBox, + readiness = readiness, + modifier = Modifier.matchParentSize(), + fitWidth = fitWidth, + fitHeight = fitHeight, + painFlags = painFlags, + ) + // Capture real canvas size so hit-test transform matches render transform + Canvas(Modifier.matchParentSize()) { canvasSize = size } + } +} + +// ViewBox, BodyPathPiece, BodySide, BodyMapDataset, loadBodyMapDataset, +// buildDisplayReadiness, and BodyHalfCanvas now live in BodyMapCanvas.kt + +private fun recoveryAgeDays(iso: String): Long { + // Use tolerant parser so both ISO-8601 instants and bare YYYY-MM-DD dates work. + // Without runCatching an unrecognised format would crash the remember block and + // bring down the entire RecoveryMapScreen composable. + val d = runCatching { + Instant.parse(iso).atZone(ZoneId.systemDefault()).toLocalDate() + }.getOrNull() + ?: runCatching { java.time.LocalDate.parse(iso.substringBefore('T')) }.getOrNull() + ?: return Long.MAX_VALUE // unknown format → treat as too old, filter out + return java.time.temporal.ChronoUnit.DAYS.between(d, java.time.LocalDate.now()) +} + +/** + * Canvas line chart showing 14-day readiness trend with coloured zone bands: + * Red 0–40% (low) + * Yellow 40–70% (moderate) + * Green 70–100% (good) + * Missing-data days are represented as gaps (no interpolation). + */ +@Composable +private fun ReadinessTrendChart( + trend14: List>, // (dateKey, score 0-100), oldest first + modifier: Modifier = Modifier, +) { + if (trend14.isEmpty()) return + val c = useTheme() + + Canvas(modifier = modifier) { + val w = size.width + val h = size.height + val padTop = 8f + val padBottom = 4f + val chartH = h - padTop - padBottom + + fun yForScore(score: Int) = padTop + chartH * (1f - score / 100f) + + // Zone bands + drawRect(color = c.danger.copy(alpha = 0.10f), topLeft = Offset(0f, yForScore(40)), size = Size(w, yForScore(0) - yForScore(40))) + drawRect(color = c.warning.copy(alpha = 0.08f), topLeft = Offset(0f, yForScore(70)), size = Size(w, yForScore(40) - yForScore(70))) + drawRect(color = c.success.copy(alpha = 0.07f), topLeft = Offset(0f, yForScore(100)), size = Size(w, yForScore(70) - yForScore(100))) + + // Zone divider lines + drawLine(c.danger.copy(alpha = 0.25f), Offset(0f, yForScore(40)), Offset(w, yForScore(40)), strokeWidth = 1f) + drawLine(c.warning.copy(alpha = 0.25f), Offset(0f, yForScore(70)), Offset(w, yForScore(70)), strokeWidth = 1f) + + // Line segments (skip gaps where score == 0 and no history) + val stepX = w / (trend14.size - 1).coerceAtLeast(1).toFloat() + val path = Path() + var penDown = false + trend14.forEachIndexed { i, (_, score) -> + val x = i * stepX + val y = yForScore(score) + if (!penDown) { + path.moveTo(x, y) + penDown = true + } else { + path.lineTo(x, y) + } + } + drawPath(path, color = c.accent, style = Stroke(width = 2.5f, cap = StrokeCap.Round)) + + // Dot on each point + trend14.forEachIndexed { i, (_, score) -> + val x = i * stepX + val y = yForScore(score) + val dotColor = when { + score >= 70 -> c.success + score >= 40 -> c.warning + else -> c.danger + } + drawCircle(dotColor, radius = 4f, center = Offset(x, y)) + drawCircle(c.card, radius = 2f, center = Offset(x, y)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RecoveryRegionSheet( + region: String, + readiness: Double, + filtered: List, + sourceLabel: String, + onDismiss: () -> Unit, +) { + val c = useTheme() + val action = when { + readiness >= 0.90 -> "Train" + readiness >= 0.72 -> "Maintain" + else -> "Back Off" + } + val hits = remember(region, filtered) { + filtered.flatMap { it.exercises } + .filter { + val m = (it.primaryMuscle ?: it.primaryMuscles.firstOrNull().orEmpty()).lowercase() + when (region) { + "chest" -> m in setOf("chest", "pectorals") + "back" -> m in setOf("back", "lats", "rhomboids", "traps", "trapezius") + "shoulders", "rearDelts" -> m in setOf("shoulders", "delts", "rear delts", "rear_delts") + "arms" -> m in setOf("arms", "forearms", "biceps", "triceps") + "quads" -> m in setOf("quads", "quadriceps") + "hamstrings" -> m in setOf("hamstrings", "glutes", "adductors") + "calves" -> m in setOf("calves", "tibialis") + "core" -> m in setOf("core", "abs", "obliques") + else -> true + } + } + .groupBy { it.name } + .entries + .sortedByDescending { it.value.size } + .take(5) + .map { "${it.key} (${it.value.size} sessions)" } + } + ModalBottomSheet(onDismissRequest = onDismiss, containerColor = c.card) { + Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + val displayRegion = region + .replace(Regex("([A-Z])"), " $1") + .trim() + .replaceFirstChar { it.titlecase() } + Text(displayRegion, color = c.text, fontSize = IronLogType.title.fontSize.sp) + Text("Readiness ${(readiness * 100).toInt()}%", color = c.subtext) + Text("Source: $sourceLabel", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + Text(action, color = c.accent) + Text("Recent contributing exercises", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, modifier = Modifier.padding(top = 4.dp)) + if (hits.isEmpty()) Text("Not enough recent data.", color = c.muted) else hits.forEach { Text("- $it", color = c.text) } + } + } +} + +@Composable +private fun ManualRecoveryCheckInModal( + initial: ManualRecoveryInput, + painFlags: Set = emptySet(), + painRegions: List = emptyList(), + onDismiss: () -> Unit, + onSave: (ManualRecoveryInput, Set) -> Unit, +) { + val c = useTheme() + var soreness by remember { mutableStateOf(initial.soreness) } + var sleep by remember { mutableStateOf(initial.sleepQuality) } + var energy by remember { mutableStateOf(initial.energy) } + var notes by remember { mutableStateOf(initial.notes) } + // GAP-20: pain flags editable in this modal + var localPainFlags by remember { mutableStateOf(painFlags) } + + Dialog(onDismissRequest = onDismiss) { + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + modifier = Modifier.fillMaxWidth().padding(16.dp) + ) { + Column( + Modifier.padding(20.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Manual Recovery Input", color = c.text, fontSize = IronLogType.title.fontSize.sp) + + ScoreRow(label = "Soreness (1-5)", value = soreness, c = c) { soreness = it } + ScoreRow(label = "Sleep (1-5)", value = sleep, c = c) { sleep = it } + ScoreRow(label = "Energy (1-5)", value = energy, c = c) { energy = it } + + // GAP-20: Pain flags per muscle region + if (painRegions.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("⚠️ Pain / Injury Flags", color = c.danger, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Bold) + Text("Flagged muscles show 0% readiness regardless of training history.", color = c.muted, fontSize = IronLogType.micro.fontSize.sp) + androidx.compose.foundation.layout.FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + painRegions.forEach { region -> + val flagged = region in localPainFlags + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(if (flagged) c.danger.copy(alpha = 0.15f) else c.surface) + .border(1.dp, if (flagged) c.danger else c.cardBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { + localPainFlags = if (flagged) localPainFlags - region else localPainFlags + region + } + .padding(horizontal = 10.dp, vertical = 5.dp), + contentAlignment = Alignment.Center, + ) { + Text( + if (flagged) "⚠ $region" else region, + color = if (flagged) c.danger else c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = if (flagged) FontWeight.Bold else FontWeight.Normal, + ) + } + } + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Notes (optional)", color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + OutlinedTextField( + value = notes, + onValueChange = { notes = it }, + placeholder = { Text("How are you feeling?", color = c.muted) }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(top = 8.dp)) { + Button( + onClick = onDismiss, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent, contentColor = c.muted), + border = androidx.compose.foundation.BorderStroke(1.dp, c.faint) + ) { + Text("Cancel") + } + Button( + onClick = { onSave(ManualRecoveryInput(soreness, sleep, energy, notes = notes), localPainFlags) }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = c.accent.copy(alpha = 0.15f), contentColor = c.accent), + border = androidx.compose.foundation.BorderStroke(1.dp, c.accent) + ) { + Text("Save") + } + } + } + } + } +} + +// buildDisplayReadiness → BodyMapCanvas.kt + +@Composable +private fun ScoreRow(label: String, value: Int, c: IronLogThemeTokens, onChange: (Int) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(label, color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + (1..5).forEach { i -> + val active = value == i + val bg = if (active) c.accent.copy(alpha = 0.15f) else Color.Transparent + val borderColor = if (active) c.accent else c.faint + val tc = if (active) c.accent else c.muted + Box( + modifier = Modifier + .weight(1f) + .height(36.dp) + .background(bg, RoundedCornerShape(8.dp)) + .border(1.dp, color = borderColor, shape = RoundedCornerShape(8.dp)) + .clickable { onChange(i) }, + contentAlignment = Alignment.Center + ) { + Text("$i", color = tc, fontWeight = FontWeight.Bold) + } + } + } + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/settings/BackupCenterScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/settings/BackupCenterScreen.kt new file mode 100644 index 0000000..e1e754d --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/settings/BackupCenterScreen.kt @@ -0,0 +1,343 @@ +package com.ironlog.app.ui.screens.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.data.repository.ImportExportRepository +import com.ironlog.app.services.BackupScheduler +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec +import java.io.File + +@Composable +fun BackupCenterScreen( + onBack: () -> Unit = {}, + onOpenDataPortability: () -> Unit = {}, + onOpenPrivacy: () -> Unit = {}, + repo: SettingsRepository = SettingsRepository(), + importRepo: ImportExportRepository = ImportExportRepository(), +) { + val c = useTheme() + val context = LocalContext.current + val scope = rememberCoroutineScope() + var status by remember { mutableStateOf("") } + var passphrase by remember { mutableStateOf("") } + var backupFiles by remember { mutableStateOf(listOf()) } + var selectedFile by remember { mutableStateOf(null) } + var preview by remember { mutableStateOf("No backup selected.") } + var replaceText by remember { mutableStateOf("") } + var showConfirm by remember { mutableStateOf(false) } + var encryptedStatus by remember { mutableStateOf("") } + var autoBackupEnabled by remember { mutableStateOf(false) } + var backupHour by remember { mutableStateOf("02") } + var backupMinute by remember { mutableStateOf("00") } + var retentionCount by remember { mutableStateOf("14") } + LaunchedEffect(Unit) { + passphrase = repo.getString("backup_passphrase_hint").orEmpty() + autoBackupEnabled = repo.getBoolean("auto_backup_enabled", false) + backupHour = repo.getString("auto_backup_hour") ?: "02" + backupMinute = repo.getString("auto_backup_minute") ?: "00" + retentionCount = ((repo.getSettingNumber("auto_backup_retention")?.toInt() ?: 14).coerceIn(1, 60)).toString() + backupFiles = withContext(Dispatchers.IO) { discoverBackups(context) } + } + + LazyColumn( + modifier = Modifier.fillMaxSize().background(c.bg).statusBarsPadding().padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 16.dp, bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { ScreenHeader(title = "BACKUP CENTER", onBack = onBack) } + item { + Text("BACKUP", color = c.accent, fontSize = IronLogType.eyebrow.fontSize.sp, fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), letterSpacing = IronLogType.eyebrow.letterSpacing.sp) + Text("Backup Center", color = c.text, fontSize = IronLogType.title.fontSize.sp, fontWeight = FontWeight(IronLogType.display.fontWeight), lineHeight = IronLogType.title.lineHeight.sp) + } + item { + Card(colors = CardDefaults.cardColors(containerColor = c.card), border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder)) { + Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Use Data Portability for full JSON/CSV export and import.", color = c.subtext) + Button(onClick = onOpenDataPortability) { Text("Open Data Portability") } + Button(onClick = onOpenPrivacy) { Text("Privacy") } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Auto Backup", color = c.text) + Switch( + checked = autoBackupEnabled, + onCheckedChange = { + autoBackupEnabled = it + scope.launch { + repo.setBoolean("auto_backup_enabled", it) + if (it) { + val h = backupHour.toIntOrNull()?.coerceIn(0, 23) ?: 2 + val m = backupMinute.toIntOrNull()?.coerceIn(0, 59) ?: 0 + BackupScheduler.scheduleDaily(context, h, m) + status = "Auto backup scheduled daily at ${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}." + } else { + BackupScheduler.cancel(context) + status = "Auto backup disabled." + } + } + }, + ) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = backupHour, + onValueChange = { backupHour = it.filter(Char::isDigit).take(2) }, + label = { Text("Hour (0-23)") }, + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = backupMinute, + onValueChange = { backupMinute = it.filter(Char::isDigit).take(2) }, + label = { Text("Minute (0-59)") }, + modifier = Modifier.weight(1f), + ) + } + OutlinedTextField( + value = retentionCount, + onValueChange = { retentionCount = it.filter(Char::isDigit).take(2) }, + label = { Text("Retention (keep N auto backups)") }, + modifier = Modifier.fillMaxWidth(), + ) + Button(onClick = { + scope.launch { + val h = backupHour.toIntOrNull()?.coerceIn(0, 23) ?: 2 + val m = backupMinute.toIntOrNull()?.coerceIn(0, 59) ?: 0 + val keep = retentionCount.toIntOrNull()?.coerceIn(1, 60) ?: 14 + backupHour = h.toString().padStart(2, '0') + backupMinute = m.toString().padStart(2, '0') + retentionCount = keep.toString() + repo.setString("auto_backup_hour", backupHour) + repo.setString("auto_backup_minute", backupMinute) + repo.setSetting("auto_backup_retention", keep, "number") + if (autoBackupEnabled) { + BackupScheduler.scheduleDaily(context, h, m) + status = "Auto backup updated: $backupHour:$backupMinute, keep last $keep." + } else { + status = "Backup settings saved. Enable Auto Backup to schedule." + } + } + }) { Text("Save Backup Schedule") } + Button(onClick = { + scope.launch { + val trimmed = passphrase.trim() + if (trimmed.length < 8) { + status = "Passphrase must be at least 8 characters." + } else { + val hint = trimmed.take(2) + "*".repeat((trimmed.length - 2).coerceAtLeast(0)) + repo.setString("backup_passphrase_hint", hint) + status = "Backup passphrase hint stored." + } + } + }) { Text("Save Passphrase Hint") } + Button(onClick = { + scope.launch { + val phrase = passphrase.trim() + if (phrase.length < 8) { + encryptedStatus = "Passphrase too short. Use 8+ chars." + } else { + runCatching { + // exportDatabase is IO, encryptPayload is CPU (PBKDF2 120k iterations) — + // neither must run on the Main dispatcher. + val exported = withContext(Dispatchers.IO) { importRepo.exportDatabase().toString() } + val encrypted = withContext(Dispatchers.Default) { encryptPayload(exported, phrase) } + val name = withContext(Dispatchers.IO) { + val out = File(context.filesDir, "ironlog_snapshot_encrypted_${System.currentTimeMillis()}.json") + out.writeText(encrypted) + out.name + } + encryptedStatus = "Encrypted snapshot saved: $name" + }.onFailure { + encryptedStatus = "Encrypted snapshot failed: ${it.message}" + } + } + } + }) { Text("Create Encrypted Snapshot") } + if (encryptedStatus.isNotBlank()) { + Text(encryptedStatus, color = c.accent, fontSize = IronLogType.meta.fontSize.sp) + } + Button(onClick = { + scope.launch { + repo.setString("last_backup_health", "ok") + backupFiles = withContext(Dispatchers.IO) { discoverBackups(context) } + status = "Backup health marked as OK." + } + }) { Text("Validate Latest Backup") } + if (status.isNotBlank()) Text(status, color = c.accent, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + item { + Card(colors = CardDefaults.cardColors(containerColor = c.card), border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder)) { + Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Restore Local Backup", color = c.text, fontSize = IronLogType.section.fontSize.sp) + if (backupFiles.isEmpty()) { + Text("No local backup JSON files found in app storage.", color = c.muted) + } else { + backupFiles.take(8).forEach { file -> + TextButton(onClick = { + selectedFile = file + preview = "Loading preview…" + // buildBackupPreview reads + parses a file — must not run on Main. + scope.launch(Dispatchers.IO) { + preview = runCatching { buildBackupPreview(file) }.getOrElse { "Preview failed: ${it.message}" } + } + }) { + Text(file.name, color = if (selectedFile?.absolutePath == file.absolutePath) c.accent else c.text) + } + } + } + Text(preview, color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + OutlinedTextField( + value = replaceText, + onValueChange = { replaceText = it }, + label = { Text("Type REPLACE to confirm restore") }, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { showConfirm = true }, + enabled = selectedFile != null && replaceText.trim().uppercase() == "REPLACE", + ) { Text("Restore Selected Backup") } + } + } + } + } + + if (showConfirm) { + AlertDialog( + onDismissRequest = { showConfirm = false }, + title = { Text("Replace existing history?") }, + text = { Text("This will clear completed workouts and restore from the selected backup. This action cannot be undone.") }, + confirmButton = { + TextButton(onClick = { + showConfirm = false + val file = selectedFile ?: return@TextButton + scope.launch { + val result = runCatching { + withContext(Dispatchers.IO) { + importRepo.runConfirmedImport(file.readText(), mode = "replace").workouts + } + } + status = result.fold( + onSuccess = { "Restore complete. Imported $it workouts." }, + onFailure = { "Restore failed: ${it.message}" }, + ) + } + }) { Text("Restore Now") } + }, + dismissButton = { TextButton(onClick = { showConfirm = false }) { Text("Cancel") } }, + ) + } +} + +private fun encryptPayload(plainText: String, passphrase: String): String { + val random = SecureRandom() + val salt = ByteArray(16).also(random::nextBytes) + val iv = ByteArray(12).also(random::nextBytes) + val keyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val spec = PBEKeySpec(passphrase.toCharArray(), salt, 120000, 256) + val keyBytes = keyFactory.generateSecret(spec).encoded + val secret = SecretKeySpec(keyBytes, "AES") + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, secret, GCMParameterSpec(128, iv)) + val encrypted = cipher.doFinal(plainText.toByteArray(StandardCharsets.UTF_8)) + val payloadHash = MessageDigest.getInstance("SHA-256").digest(plainText.toByteArray(StandardCharsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + return JSONObject() + .put("schema", "IRONLOG_ENCRYPTED_EXPORT_V1") + .put("kdf", "PBKDF2WithHmacSHA256") + .put("iterations", 120000) + .put("saltB64", android.util.Base64.encodeToString(salt, android.util.Base64.NO_WRAP)) + .put("ivB64", android.util.Base64.encodeToString(iv, android.util.Base64.NO_WRAP)) + .put("ciphertextB64", android.util.Base64.encodeToString(encrypted, android.util.Base64.NO_WRAP)) + .put("sha256", payloadHash) + .put("exportedAt", System.currentTimeMillis()) + .toString(2) +} + +private fun buildBackupPreview(file: File): String { + val text = file.readText() + // New watermelon-format backup (JSONObject root with type = "ironlog_watermelon_export") + runCatching { + val obj = JSONObject(text) + if (obj.optString("type") == "ironlog_watermelon_export") { + val data = obj.optJSONObject("data") ?: return "Invalid backup: missing data section." + val workouts = data.optJSONArray("workouts")?.length() ?: 0 + val plans = data.optJSONArray("plans")?.length() ?: 0 + val sets = data.optJSONArray("workout_sets")?.length() ?: 0 + val body = data.optJSONArray("body_measurements")?.length() ?: 0 + val exportedAt = obj.optString("exportedAt", "unknown date") + return "Backup ($exportedAt)\nworkouts=$workouts · plans=$plans · sets=$sets · body=$body" + } + } + // Legacy JSONArray format (old app versions exported an array of workout objects) + runCatching { + val arr = JSONArray(text) + var workouts = 0; var exercises = 0; var sets = 0 + for (i in 0 until arr.length()) { + val w = arr.optJSONObject(i) ?: continue + val ex = w.optJSONArray("exercises") ?: JSONArray() + var setsInWorkout = 0 + for (j in 0 until ex.length()) { + val e = ex.optJSONObject(j) ?: continue + val s = e.optJSONArray("sets") ?: JSONArray() + if (s.length() > 0) exercises++ + sets += s.length(); setsInWorkout += s.length() + } + if (setsInWorkout > 0) workouts++ + } + return "Legacy backup: workouts=$workouts · exercises=$exercises · sets=$sets" + } + // Encrypted snapshots (schema = "IRONLOG_ENCRYPTED_EXPORT_V1") reach here — correct message. + return "Could not parse backup file. File may be encrypted or corrupted." +} + +private fun discoverBackups(context: android.content.Context): List { + val root = context.filesDir + val autoDir = File(root, "backups") + val files = (root.listFiles().orEmpty().toList() + autoDir.listFiles().orEmpty().toList()) + .filter { it.isFile && it.name.startsWith("ironlog_backup_") && it.extension.equals("json", ignoreCase = true) } + return files.sortedByDescending { it.lastModified() } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/settings/CreateExerciseScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/settings/CreateExerciseScreen.kt new file mode 100644 index 0000000..03379e2 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/settings/CreateExerciseScreen.kt @@ -0,0 +1,371 @@ +package com.ironlog.app.ui.screens.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.TextButton +import androidx.compose.runtime.LaunchedEffect +import com.ironlog.app.data.model.CreateExerciseInput +import com.ironlog.app.data.model.LegacyExerciseShape +import com.ironlog.app.data.repository.ExerciseRepository +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import kotlinx.coroutines.launch + +private val MUSCLE_OPTIONS = listOf( + "Chest", "Back", "Shoulders", "Biceps", "Triceps", "Forearms", + "Abs", "Obliques", "Glutes", "Quads", "Hamstrings", "Calves", "Lower Back", +) +private val EQUIPMENT_OPTIONS = listOf( + "Barbell", "Dumbbell", "Cable", "Machine", "Bodyweight", "Band", "Kettlebell", "Other", +) + +private data class TrackingOption(val value: String, val label: String) +private val TRACKING_OPTIONS = listOf( + TrackingOption("weight_reps", "Weight × Reps"), + TrackingOption("bodyweight", "Bodyweight"), + TrackingOption("duration", "Duration"), +) +private val MOVEMENT_PATTERN_OPTIONS = listOf( + "Push", "Pull", "Hinge", "Squat", "Carry", "Rotation", "Isolation", +) +private val DIFFICULTY_OPTIONS = listOf("beginner", "intermediate", "advanced", "expert") + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +fun CreateExerciseScreen( + initialName: String = "", + repo: ExerciseRepository = ExerciseRepository(), + onSaved: () -> Unit = {}, + onBack: (() -> Unit)? = null, +) { + val c = useTheme() + val scope = rememberCoroutineScope() + var name by remember { mutableStateOf(initialName) } + var primaryMuscle by remember { mutableStateOf(MUSCLE_OPTIONS.first()) } + var equipment by remember { mutableStateOf(EQUIPMENT_OPTIONS.first()) } + var trackingType by remember { mutableStateOf(TRACKING_OPTIONS.first().value) } + var movementPattern by remember { mutableStateOf(null) } + var difficulty by remember { mutableStateOf(null) } + var secondaryMuscles by remember { mutableStateOf>(emptySet()) } + var error by remember { mutableStateOf(null) } + var allExercises by remember { mutableStateOf>(emptyList()) } + var showCopyFrom by remember { mutableStateOf(false) } + var copySearch by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { allExercises = repo.getExercisesSnapshot() } + + Column( + Modifier + .fillMaxSize() + .background(c.bg) + .statusBarsPadding(), + ) { + // ── Top bar ────────────────────────────────────────────────────────── + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (onBack != null) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back", tint = c.accent) + } + } + Text( + "Create Exercise", + color = c.text, + fontWeight = FontWeight.Bold, + fontSize = IronLogType.section.fontSize.sp, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { showCopyFrom = true; copySearch = "" }) { + Text("Copy from...", color = c.accent, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Bold) + } + } + + // Copy from exercise picker + if (showCopyFrom) { + ModalBottomSheet( + onDismissRequest = { showCopyFrom = false }, + containerColor = c.card, + ) { + Column( + Modifier.padding(horizontal = 16.dp).padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Copy from Exercise", color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.section.fontSize.sp) + OutlinedTextField( + value = copySearch, + onValueChange = { copySearch = it }, + placeholder = { Text("Search...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + val copyFiltered = remember(allExercises, copySearch) { + if (copySearch.isBlank()) allExercises.take(40) + else allExercises.filter { it.name.contains(copySearch, ignoreCase = true) }.take(40) + } + LazyColumn(Modifier.height(320.dp)) { + items(copyFiltered, key = { it.id }) { ex -> + Column { + Row( + Modifier + .fillMaxWidth() + .clickable { + name = ex.name + val matchedMuscle = MUSCLE_OPTIONS.firstOrNull { m -> m.equals(ex.primaryMuscle, ignoreCase = true) } + if (matchedMuscle != null) primaryMuscle = matchedMuscle + val matchedEquip = EQUIPMENT_OPTIONS.firstOrNull { e -> e.equals(ex.equipment, ignoreCase = true) } + if (matchedEquip != null) equipment = matchedEquip + val matchedTrack = TRACKING_OPTIONS.firstOrNull { t -> t.value == ex.trackingType } + if (matchedTrack != null) trackingType = matchedTrack.value + val matchedMove = MOVEMENT_PATTERN_OPTIONS.firstOrNull { p -> p.equals(ex.movementPattern, ignoreCase = true) } + movementPattern = matchedMove + val matchedDiff = DIFFICULTY_OPTIONS.firstOrNull { d -> d.equals(ex.difficulty, ignoreCase = true) } + difficulty = matchedDiff + showCopyFrom = false + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(ex.name, color = c.text, fontSize = IronLogType.body.fontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (ex.primaryMuscle != null) { + Text("${ex.primaryMuscle} · ${ex.equipment}", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + HorizontalDivider(color = c.faint, thickness = 0.5.dp) + } + } + } + } + } + } + + // ── Scrollable body ────────────────────────────────────────────────── + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + // Name + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Exercise name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + // Primary muscle + SelectorSection(label = "Primary Muscle") { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + MUSCLE_OPTIONS.forEach { m -> + SelectChip( + label = m, + selected = primaryMuscle == m, + onClick = { primaryMuscle = m }, + ) + } + } + } + + // Secondary muscles (GAP-05) + SelectorSection(label = "Secondary Muscles (optional)") { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + MUSCLE_OPTIONS.forEach { m -> + // Can't select same as primary; toggle off primary if selected as secondary + val isDisabled = m == primaryMuscle + SelectChip( + label = m, + selected = m in secondaryMuscles && !isDisabled, + onClick = { + if (!isDisabled) { + secondaryMuscles = if (m in secondaryMuscles) secondaryMuscles - m else secondaryMuscles + m + } + }, + ) + } + } + } + + // Equipment + SelectorSection(label = "Equipment") { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + EQUIPMENT_OPTIONS.forEach { e -> + SelectChip( + label = e, + selected = equipment == e, + onClick = { equipment = e }, + ) + } + } + } + + // Tracking type + SelectorSection(label = "Tracking Type") { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + TRACKING_OPTIONS.forEach { opt -> + SelectChip( + label = opt.label, + selected = trackingType == opt.value, + onClick = { trackingType = opt.value }, + ) + } + } + } + + // Movement Pattern + SelectorSection(label = "Movement Pattern (optional)") { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + MOVEMENT_PATTERN_OPTIONS.forEach { pattern -> + SelectChip( + label = pattern, + selected = movementPattern == pattern, + onClick = { movementPattern = if (movementPattern == pattern) null else pattern }, + ) + } + } + } + + // Difficulty + SelectorSection(label = "Difficulty (optional)") { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + DIFFICULTY_OPTIONS.forEach { diff -> + SelectChip( + label = diff.replaceFirstChar { it.titlecase() }, + selected = difficulty == diff, + onClick = { difficulty = if (difficulty == diff) null else diff }, + ) + } + } + } + + // Error + error?.let { + Text(it, color = c.danger, fontSize = IronLogType.body.fontSize.sp) + } + + // Save button + Button( + onClick = { + scope.launch { + runCatching { + repo.createCustomExercise( + CreateExerciseInput( + name = name.trim(), + primaryMuscle = primaryMuscle, + equipment = equipment, + category = "strength", + trackingType = trackingType, + movementPattern = movementPattern, + difficulty = difficulty, + secondaryMuscles = secondaryMuscles.toList().takeIf { it.isNotEmpty() }, + ) + ) + }.onSuccess { + onSaved() + }.onFailure { + error = it.message ?: "Could not create exercise" + } + } + }, + enabled = name.trim().isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text("SAVE EXERCISE", fontWeight = FontWeight.Bold) + } + + Spacer(Modifier.navigationBarsPadding()) + } + } +} + +@Composable +private fun SelectorSection(label: String, content: @Composable () -> Unit) { + val c = useTheme() + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(label, color = c.muted, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.5.sp) + content() + } +} + +@Composable +private fun SelectChip(label: String, selected: Boolean, onClick: () -> Unit) { + val c = useTheme() + Box( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(if (selected) c.accentSoft else c.surface) + .border(1.dp, if (selected) c.accentBorder else c.faint, RoundedCornerShape(20.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 12.dp), + ) { + Text( + label, + color = if (selected) c.accent else c.subtext, + fontSize = IronLogType.body.fontSize.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + ) + } +} + + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/settings/DataPortabilityScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/settings/DataPortabilityScreen.kt new file mode 100644 index 0000000..d110cd4 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/settings/DataPortabilityScreen.kt @@ -0,0 +1,754 @@ +package com.ironlog.app.ui.screens.settings + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import com.ironlog.app.data.model.CompletedExerciseInput +import com.ironlog.app.data.model.CreateCompletedWorkoutInput +import com.ironlog.app.data.model.SetInput +import com.ironlog.app.data.objectbox.AppSettingEntity +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.data.objectbox.WorkoutEntity +import com.ironlog.app.data.repository.ImportExportRepository +import com.ironlog.app.data.repository.WorkoutRepository +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.util.FuzzyExerciseMapper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +private val SOURCE_FORMATS = listOf( + "ironlog_json" to "IronLog Backup", + "strong_csv" to "Strong CSV", + "hevy_csv" to "Hevy CSV", + "openweight_json" to "OpenWeight JSON", +) + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun DataPortabilityScreen( + sourceHint: String = "ironlog_json", + onBack: () -> Unit = {}, + onRestoreComplete: () -> Unit = {}, + repo: WorkoutRepository = WorkoutRepository(), + importExportRepo: ImportExportRepository = ImportExportRepository(), +) { + val c = useTheme() + val ctx = LocalContext.current + val scope = rememberCoroutineScope() + + var selectedSource by remember { mutableStateOf(sourceHint.ifBlank { "ironlog_json" }) } + var pickedFileText by remember { mutableStateOf("") } + var preview by remember { mutableStateOf(null) } + var status by remember { mutableStateOf("") } + var isWorking by remember { mutableStateOf(false) } + var showConfirm by remember { mutableStateOf(false) } + + val importFilePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch(Dispatchers.IO) { + val text = runCatching { + ctx.contentResolver.openInputStream(uri)?.bufferedReader()?.readText().orEmpty() + }.getOrElse { e -> + withContext(Dispatchers.Main) { status = "Could not read file: ${e.message}" } + return@launch + } + val pv = buildImportPreview(text, selectedSource) + withContext(Dispatchers.Main) { pickedFileText = text; preview = pv; status = "" } + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize().background(c.bg).statusBarsPadding(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { ScreenHeader(title = "DATA PORTABILITY", onBack = onBack) } + + // ── EXPORT ──────────────────────────────────────────────────────────── + item { + Text("EXPORT", color = c.accent, fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), letterSpacing = IronLogType.eyebrow.letterSpacing.sp) + } + item { + Card(colors = CardDefaults.cardColors(containerColor = c.card), border = BorderStroke(1.dp, c.cardBorder)) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Share your data as a file. JSON is the full backup; CSV is compatible with spreadsheets.", + color = c.subtext, fontSize = IronLogType.body.fontSize.sp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { + isWorking = true + scope.launch(Dispatchers.IO) { + val res = runCatching { buildJsonExportFile(ctx, importExportRepo) } + withContext(Dispatchers.Main) { + isWorking = false + res.onSuccess { f -> + runCatching { shareFile(ctx, f, "application/json") } + .onFailure { e -> status = "Share failed: ${e.message}" } + }.onFailure { status = "Export failed: ${it.message}" } + } + } + }, + enabled = !isWorking, + ) { Text("Share JSON") } + Button( + onClick = { + isWorking = true + scope.launch(Dispatchers.IO) { + val res = runCatching { buildCsvExportFile(ctx, repo) } + withContext(Dispatchers.Main) { + isWorking = false + res.onSuccess { f -> + runCatching { shareFile(ctx, f, "text/csv") } + .onFailure { e -> status = "Share failed: ${e.message}" } + }.onFailure { status = "Export failed: ${it.message}" } + } + } + }, + enabled = !isWorking, + ) { Text("Share CSV") } + } + Text( + "ℹ Progress photos are not included — re-link them manually if restoring to a new device.", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } + } + + // ── IMPORT ──────────────────────────────────────────────────────────── + item { + Text("IMPORT", color = c.accent, fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), letterSpacing = IronLogType.eyebrow.letterSpacing.sp) + } + item { + Text("Choose your source format, then pick the file. A preview appears before anything is written.", + color = c.subtext, fontSize = IronLogType.body.fontSize.sp) + } + item { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SOURCE_FORMATS.forEach { (id, label) -> + val sel = selectedSource == id + Text( + label, + color = if (sel) c.accent else c.muted, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = if (sel) FontWeight.Bold else FontWeight.Normal, + modifier = Modifier + .clip(RoundedCornerShape(50.dp)) + .background(if (sel) c.accentSoft else c.card) + .border(1.dp, if (sel) c.accentBorder else c.cardBorder, RoundedCornerShape(50.dp)) + .clickable { selectedSource = id; preview = null; pickedFileText = "" } + .padding(horizontal = 14.dp, vertical = 6.dp), + ) + } + } + } + item { + Button( + onClick = { + val mime = if (selectedSource.endsWith("_json")) + arrayOf("application/json", "text/plain", "*/*") + else + arrayOf("text/comma-separated-values", "text/csv", "text/plain", "*/*") + importFilePicker.launch(mime) + }, + enabled = !isWorking, + ) { Text("Pick File") } + } + + // Preview card + preview?.let { pv -> + item { + Card(colors = CardDefaults.cardColors(containerColor = c.card), border = BorderStroke(1.dp, c.cardBorder)) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Preview", color = c.text, fontSize = IronLogType.section.fontSize.sp, fontWeight = FontWeight.Bold) + Text("${pv.validRows} rows ready", color = c.subtext) + if (pv.domainCounts.isNotEmpty()) + Text(pv.domainCounts.entries.joinToString(" · ") { "${it.key}: ${it.value}" }, + color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + pv.warnings.take(3).forEach { Text("⚠ $it", color = c.warning, fontSize = IronLogType.meta.fontSize.sp) } + pv.samples.forEach { Text(it, color = c.muted, fontSize = IronLogType.meta.fontSize.sp) } + if (pv.error != null) Text("Error: ${pv.error}", color = c.danger, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + item { + Button( + onClick = { showConfirm = true }, + enabled = pv.canImport && !isWorking, + ) { Text(if (selectedSource == "ironlog_json") "Restore ${pv.validRows} rows" else "Import ${pv.validRows} rows") } + } + } + + if (status.isNotBlank()) item { Text(status, color = c.accent) } + } + + if (showConfirm) { + AlertDialog( + onDismissRequest = { showConfirm = false }, + title = { Text(if (selectedSource == "ironlog_json") "Restore backup?" else "Import data?") }, + text = { + Text( + if (selectedSource == "ironlog_json") { + "${preview?.validRows ?: 0} backup rows will be restored. Matching rows are updated by stable ID, so seeded exercises and repeated restores will not create duplicates." + } else { + "${preview?.validRows ?: 0} rows will be added. Importing the same file twice may create duplicates." + } + ) + }, + confirmButton = { + TextButton(onClick = { + showConfirm = false + isWorking = true + val pv = preview ?: return@TextButton + val text = pickedFileText + val src = selectedSource + scope.launch(Dispatchers.IO) { + val replaceMode = src == "ironlog_json" + val res = runCatching { importText(text, repo, importExportRepo, pv, src, replaceMode) } + withContext(Dispatchers.Main) { + status = res.fold( + { + if (src == "ironlog_json") onRestoreComplete() + if (src == "ironlog_json") "Restored $it rows." else "Imported $it rows." + }, + { "Failed: ${it.message}" }, + ) + isWorking = false; preview = null; pickedFileText = "" + } + } + }) { Text(if (selectedSource == "ironlog_json") "Restore" else "Import") } + }, + dismissButton = { TextButton(onClick = { showConfirm = false }) { Text("Cancel") } }, + ) + } +} + +private fun shareFile(context: android.content.Context, file: File, mimeType: String) { + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + val intent = Intent(Intent.ACTION_SEND).apply { + type = mimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, file.name)) +} + +private suspend fun buildJsonExportFile(context: android.content.Context, importExportRepo: ImportExportRepository): File { + val out = importExportRepo.exportDatabase() + val fn = "ironlog_backup_${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"))}.json" + val dir = File(context.filesDir, "exports").also { it.mkdirs() } + return File(dir, fn).also { it.writeText(out.toString(2)) } +} + +private suspend fun buildCsvExportFile(context: android.content.Context, repo: WorkoutRepository): File { + val all = repo.getCompletedWorkoutsFlow().firstValue() + val sb = StringBuilder("Date,Workout Name,Exercise Name,Set Order,Weight,Reps,RPE,Distance,Seconds,Notes,Workout Notes\n") + val exNameByUid = com.ironlog.app.data.objectbox.ObjectBox.store + .boxFor(com.ironlog.app.data.objectbox.ExerciseEntity::class.java) + .all.associate { it.uid to it.name } + all.forEach { w -> + val detail = repo.getWorkoutDetailSnapshot(w.uid) + val date = runCatching { + java.time.Instant.ofEpochMilli(w.startedAt).atZone(java.time.ZoneId.systemDefault()).toLocalDate().toString() + }.getOrElse { "" } + detail.exercises.forEach { we -> + detail.sets.filter { it.workoutExerciseUid == we.uid }.forEach { s -> + sb.append(listOf(date, w.name.csvSafe(), (exNameByUid[we.exerciseUid].orEmpty()).csvSafe(), + s.setIndex.toString(), s.weight.toString(), s.reps.toString(), + (s.rpe ?: "").toString(), "", "", "", w.notes.csvSafe()).joinToString(",")).append("\n") + } + } + } + val fn = "ironlog_export_${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"))}.csv" + val dir = File(context.filesDir, "exports").also { it.mkdirs() } + return File(dir, fn).also { it.writeText(sb.toString()) } +} + +internal data class CsvPreview( + val validRows: Int, + val samples: List, + val error: String?, + val domainCounts: Map = emptyMap(), + val warnings: List = emptyList(), +) + +internal fun buildImportPreview(text: String, sourceHint: String): CsvPreview { + return when (sourceHint.lowercase()) { + "ironlog_json" -> buildIronlogBackupPreview(text) + "openweight_json" -> buildOpenWeightPreview(text) + "hevy_csv" -> buildHevyPreview(text) + else -> buildStrongPreview(text) + } +} + +private fun buildIronlogBackupPreview(text: String): CsvPreview { + val repo = ImportExportRepository() + val p = repo.previewImportPayload(text) + if (!p.valid) return CsvPreview(0, emptyList(), p.reason ?: "Invalid backup") + val totalRows = p.counts.exercises + + p.counts.exerciseMuscles + + p.counts.plans + + p.counts.planDays + + p.counts.planExercises + + p.counts.workouts + + p.counts.workoutExercises + + p.counts.sets + + p.counts.bodyMeasurements + + p.counts.progressPhotos + + p.counts.settings + + p.counts.athleteCalibrations + + p.counts.gamificationProfiles + + p.counts.ironLedgerEvents + + return CsvPreview( + validRows = totalRows, + samples = listOf("plans: ${p.plans}", "workouts: ${p.workouts}", "sets: ${p.sets}"), + error = null, + domainCounts = mapOf( + "plans" to p.counts.plans, + "workouts" to p.counts.workouts, + "sets" to p.counts.sets, + "body" to p.counts.bodyMeasurements, + "custom_exercises" to p.customExercises.size, + "progress_photos" to p.counts.progressPhotos, + "settings" to p.counts.settings, + "exercise_muscles" to p.counts.exerciseMuscles, + "athlete" to p.counts.athleteCalibrations, + "ledger" to p.counts.gamificationProfiles + p.counts.ironLedgerEvents, + ), + warnings = p.warnings, + ) +} + +private fun buildStrongPreview(text: String): CsvPreview { + val rows = text.lines().map { it.trim() }.filter { it.isNotBlank() } + if (rows.isEmpty()) return CsvPreview(0, emptyList(), null) + val header = rows.first().lowercase().replace(" ", "") + val expectedCurrent = listOf("date", "workout", "exerciseuid", "weight", "reps") + val expectedRn = listOf("date", "workoutname", "exercisename", "weight", "reps") + if (!expectedCurrent.all { header.contains(it) } && !expectedRn.all { header.contains(it) }) { + return CsvPreview(0, emptyList(), "Header must contain: date, workout, exercise, weight, reps") + } + val data = rows.drop(1) + var valid = 0 + val workoutKeys = linkedSetOf() + val exerciseKeys = linkedSetOf() + val samples = mutableListOf() + data.forEach { line -> + val p = parseCsvLine(line) + val dateOk = parseDateToMillis(p.getOrNull(0).orEmpty()) != null + val weight = p.getOrNull(4)?.toDoubleOrNull() ?: p.getOrNull(3)?.toDoubleOrNull() + val reps = p.getOrNull(5)?.toDoubleOrNull() ?: p.getOrNull(4)?.toDoubleOrNull() + if (p.size >= 5 && dateOk && weight != null && reps != null) { + valid++ + val workoutName = p.getOrNull(1).orEmpty() + val exercise = p.getOrNull(2).orEmpty() + workoutKeys += "${p.getOrNull(0)}|$workoutName" + exerciseKeys += "$workoutName|$exercise" + if (samples.size < 3) samples += "$exercise - $weight x $reps" + } + } + return CsvPreview( + validRows = valid, + samples = samples, + error = if (valid == 0) "No valid rows found." else null, + domainCounts = mapOf("workouts" to workoutKeys.size, "exercises" to exerciseKeys.size, "sets" to valid), + ) +} + +private fun buildHevyPreview(text: String): CsvPreview { + val rows = text.lines().map { it.trim() }.filter { it.isNotBlank() } + if (rows.isEmpty()) return CsvPreview(0, emptyList(), null) + val header = rows.first().lowercase().replace(" ", "") + val expected = listOf("date", "exercise", "weight", "reps") + if (!expected.all { header.contains(it) }) return CsvPreview(0, emptyList(), "Hevy CSV header must contain: date, exercise, weight, reps") + var valid = 0 + val workoutKeys = linkedSetOf() + val exerciseKeys = linkedSetOf() + val samples = mutableListOf() + rows.drop(1).forEach { line -> + val p = parseCsvLine(line) + if (p.size >= 4 && p[0].isNotBlank() && p[2].toDoubleOrNull() != null && p[3].toDoubleOrNull() != null) { + valid++ + workoutKeys += p[0] + exerciseKeys += "${p[0]}|${p[1]}" + if (samples.size < 3) samples += "${p[1]} - ${p[2]} x ${p[3]}" + } + } + return CsvPreview( + validRows = valid, + samples = samples, + error = if (valid == 0) "No valid Hevy rows found." else null, + domainCounts = mapOf("workouts" to workoutKeys.size, "exercises" to exerciseKeys.size, "sets" to valid), + ) +} + +private fun buildOpenWeightPreview(text: String): CsvPreview { + val payload = runCatching { JSONObject(text) }.getOrNull() + ?: return CsvPreview(0, emptyList(), "OpenWeight payload must be valid JSON object") + val workouts = payload.optJSONArray("workouts") + ?: return CsvPreview(0, emptyList(), "OpenWeight JSON must contain 'workouts' array") + + var validWorkouts = 0 + var validExercises = 0 + var validSets = 0 + val samples = mutableListOf() + for (i in 0 until workouts.length()) { + val w = workouts.optJSONObject(i) ?: continue + val ex = w.optJSONArray("exercises") ?: continue + var setsInWorkout = 0 + for (j in 0 until ex.length()) { + val exObj = ex.optJSONObject(j) ?: continue + val sets = exObj.optJSONArray("sets") ?: JSONArray() + if (sets.length() > 0) { + validExercises++ + setsInWorkout += sets.length() + } + } + if (setsInWorkout > 0) { + validWorkouts++ + validSets += setsInWorkout + if (samples.size < 3) samples += "${w.optString("name", "Workout")} - exercises: ${ex.length()} - sets: $setsInWorkout" + } + } + return CsvPreview( + validRows = validWorkouts, + samples = samples, + error = if (validWorkouts == 0) "No valid workouts found in OpenWeight payload." else null, + domainCounts = mapOf("workouts" to validWorkouts, "exercises" to validExercises, "sets" to validSets), + ) +} + +internal suspend fun importText(text: String, repo: WorkoutRepository, importExportRepo: ImportExportRepository, preview: CsvPreview, sourceHint: String, replaceMode: Boolean): Int { + if (replaceMode && sourceHint.lowercase() != "ironlog_json") repo.clearCompletedWorkouts() + val imported = when (sourceHint.lowercase()) { + "ironlog_json" -> importIronlogBackup(text, importExportRepo, replaceMode) + "openweight_json" -> importOpenWeight(text, repo, preview) + "hevy_csv" -> importHevy(text, repo, preview) + else -> importStrong(text, repo, preview) + } + if (imported > 0) markLedgerImportedHistory() + return imported +} + +private fun markLedgerImportedHistory() { + ObjectBox.store.boxFor(AppSettingEntity::class.java).put(AppSettingEntity().apply { + key = "ledger_imported_history" + value = "true" + valueType = "boolean" + updatedAt = System.currentTimeMillis() + }) +} + +private suspend fun importIronlogBackup(text: String, importExportRepo: ImportExportRepository, replaceMode: Boolean): Int { + val result = importExportRepo.runConfirmedImport(text, mode = if (replaceMode) "replace" else "append") + return result.counts.exercises + + result.counts.exerciseMuscles + + result.counts.plans + + result.counts.planDays + + result.counts.planExercises + + result.counts.workouts + + result.counts.workoutExercises + + result.counts.sets + + result.counts.bodyMeasurements + + result.counts.progressPhotos + + result.counts.settings + + result.counts.athleteCalibrations + + result.counts.gamificationProfiles + + result.counts.ironLedgerEvents +} + +internal val CsvPreview.canImport: Boolean + get() = error == null && validRows > 0 + +private suspend fun importStrong(text: String, repo: WorkoutRepository, preview: CsvPreview): Int { + require(preview.error == null) { preview.error ?: "Invalid CSV" } + val rows = text.lines().map { it.trim() }.filter { it.isNotBlank() } + if (rows.size <= 1) return 0 + val headerCols = parseCsvLine(rows.first()).map { it.trim().lowercase().replace(" ", "") } + fun idx(vararg names: String): Int = headerCols.indexOfFirst { col -> names.any { col.contains(it) } } + val dateIdx = idx("date", "start_time", "start") + val workoutIdx = idx("workoutname", "workout") + val exerciseIdx = idx("exercisename", "exerciseuid", "exercise") + val weightIdx = idx("weight") + val repsIdx = idx("reps", "rep") + val rpeIdx = idx("rpe") + val notesIdx = idx("notes") + val workoutNotesIdx = idx("workoutnotes") + val allExercises = com.ironlog.app.data.objectbox.ObjectBox.store.boxFor(com.ironlog.app.data.objectbox.ExerciseEntity::class.java).all + val mapper = FuzzyExerciseMapper(allExercises) + + data class StrongRow( + val dateMillis: Long, + val workoutName: String, + val exUid: String, + val weight: Double, + val reps: Double, + val restSeconds: Int, + val rpe: Double?, + val note: String?, + val workoutNote: String?, + ) + val parsed = rows.drop(1).mapNotNull { line -> + val p = parseCsvLine(line) + if (p.size < 5) return@mapNotNull null + val dateMillis = parseDateToMillis(p.getOrNull(dateIdx).orEmpty()) ?: return@mapNotNull null + val workoutName = p.getOrNull(workoutIdx).orEmpty().ifBlank { "Imported Workout" } + val exUid = p.getOrNull(exerciseIdx).orEmpty().ifBlank { return@mapNotNull null } + val weight = p.getOrNull(weightIdx).orEmpty().toDoubleOrNull() ?: return@mapNotNull null + val reps = p.getOrNull(repsIdx).orEmpty().toDoubleOrNull() ?: return@mapNotNull null + val restSeconds = 0 + val rpe = if (rpeIdx >= 0) p.getOrNull(rpeIdx)?.toDoubleOrNull() else null + val note = if (notesIdx >= 0) p.getOrNull(notesIdx)?.takeIf { it.isNotBlank() } else null + val workoutNote = if (workoutNotesIdx >= 0) p.getOrNull(workoutNotesIdx)?.takeIf { it.isNotBlank() } else null + StrongRow(dateMillis, workoutName, exUid, weight, reps, restSeconds, rpe, note, workoutNote) + } + val grouped = parsed.groupBy { "${it.dateMillis}|${it.workoutName}" } + var imported = 0 + grouped.values.forEach { group -> + val first = group.firstOrNull() ?: return@forEach + val exercises = group + .groupBy { it.exUid } + .map { (exUid, sets) -> + val looksLikeUid = exUid.contains("-") && exUid.length >= 12 + val matchedId = mapper.match(exUid) + CompletedExerciseInput( + exerciseId = matchedId ?: if (looksLikeUid) exUid else null, + name = if (matchedId != null || looksLikeUid) null else exUid, + note = sets.firstNotNullOfOrNull { it.note }, + sets = sets.map { SetInput(weight = it.weight, reps = it.reps, restSeconds = it.restSeconds, rpe = it.rpe) }, + ) + } + if (exercises.isEmpty()) return@forEach + repo.createCompletedWorkout( + CreateCompletedWorkoutInput( + name = first.workoutName, + startedAt = first.dateMillis, + durationSeconds = 1800, + notes = first.workoutNote, + exerciseData = exercises, + ), + ) + imported++ + } + return imported +} + +private suspend fun importHevy(text: String, repo: WorkoutRepository, preview: CsvPreview): Int { + require(preview.error == null) { preview.error ?: "Invalid Hevy CSV" } + val rows = text.lines().map { it.trim() }.filter { it.isNotBlank() } + if (rows.size <= 1) return 0 + val headerCols = parseCsvLine(rows.first()).map { it.trim().lowercase() } + fun idx(vararg names: String): Int = headerCols.indexOfFirst { col -> names.any { col.contains(it) } } + val dateIdx = idx("date", "start_time", "start") + val nameIdx = idx("exercise", "exercise_name", "name") + val weightIdx = idx("weight", "kg", "lbs") + val repsIdx = idx("reps", "rep") + if (nameIdx < 0 || weightIdx < 0 || repsIdx < 0) return 0 + val allExercises = com.ironlog.app.data.objectbox.ObjectBox.store.boxFor(com.ironlog.app.data.objectbox.ExerciseEntity::class.java).all + val mapper = FuzzyExerciseMapper(allExercises) + + data class HevyRow(val dayKey: String, val exerciseName: String, val weight: Double, val reps: Double) + val parsed = rows.drop(1).mapNotNull { line -> + val p = parseCsvLine(line) + val ex = p.getOrNull(nameIdx)?.ifBlank { null } ?: return@mapNotNull null + val weight = p.getOrNull(weightIdx)?.toDoubleOrNull() ?: return@mapNotNull null + val reps = p.getOrNull(repsIdx)?.toDoubleOrNull() ?: return@mapNotNull null + val dayRaw = if (dateIdx >= 0) p.getOrNull(dateIdx).orEmpty() else "" + val dayKey = if (dayRaw.isNotBlank()) dayRaw.take(10) else java.time.LocalDate.now().toString() + HevyRow(dayKey, ex, weight, reps) + } + val grouped = parsed.groupBy { it.dayKey } + var imported = 0 + grouped.values.forEach { group -> + val exercises = group.groupBy { it.exerciseName }.map { (name, sets) -> + val matchedId = mapper.match(name) + CompletedExerciseInput( + exerciseId = matchedId, + name = if (matchedId != null) null else name, + sets = sets.map { SetInput(weight = it.weight, reps = it.reps, restSeconds = 90) }, + ) + } + if (exercises.isEmpty()) return@forEach + val startedAt = runCatching { java.time.LocalDate.parse(group.first().dayKey).atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() } + .getOrElse { System.currentTimeMillis() } + repo.createCompletedWorkout( + CreateCompletedWorkoutInput( + name = "Hevy Import", + startedAt = startedAt, + durationSeconds = 1800, + exerciseData = exercises, + ), + ) + imported++ + } + return imported +} + +private suspend fun importOpenWeight(text: String, repo: WorkoutRepository, preview: CsvPreview): Int { + require(preview.error == null) { preview.error ?: "Invalid OpenWeight JSON" } + val payload = JSONObject(text) + val workouts = payload.optJSONArray("workouts") ?: return 0 + var imported = 0 + val allExercises = com.ironlog.app.data.objectbox.ObjectBox.store.boxFor(com.ironlog.app.data.objectbox.ExerciseEntity::class.java).all + val mapper = FuzzyExerciseMapper(allExercises) + for (i in 0 until workouts.length()) { + val w = workouts.optJSONObject(i) ?: continue + val exArr = w.optJSONArray("exercises") ?: continue + val completed = mutableListOf() + for (j in 0 until exArr.length()) { + val ex = exArr.optJSONObject(j) ?: continue + val name = ex.optString("name", "Exercise") + val setsJson = ex.optJSONArray("sets") ?: JSONArray() + val sets = mutableListOf() + for (k in 0 until setsJson.length()) { + val s = setsJson.optJSONObject(k) ?: continue + val wt = s.optDouble("weight", Double.NaN) + val rp = s.optDouble("reps", Double.NaN) + if (wt.isNaN() || rp.isNaN()) continue + sets += SetInput(weight = wt, reps = rp, restSeconds = s.optInt("restSeconds", 90)) + } + if (sets.isNotEmpty()) { + val matchedId = mapper.match(name) + completed += CompletedExerciseInput( + exerciseId = matchedId, + name = if (matchedId != null) null else name, + sets = sets + ) + } + } + if (completed.isEmpty()) continue + repo.createCompletedWorkout( + CreateCompletedWorkoutInput( + name = w.optString("name", "OpenWeight Import"), + startedAt = w.optLong("startedAt", System.currentTimeMillis()), + durationSeconds = w.optInt("durationSeconds", 1800), + exerciseData = completed, + ) + ) + imported++ + } + return imported +} + +private suspend fun Flow>.firstValue(): List = + withContext(Dispatchers.IO) { this@firstValue.first() } + +private fun String.csvSafe(): String { + val escaped = replace("\"", "\"\"") + return "\"$escaped\"" +} + +private fun parseDateToMillis(raw: String): Long? { + val value = raw.trim() + if (value.isBlank()) return null + value.toLongOrNull()?.let { return it } + runCatching { return java.time.Instant.parse(value).toEpochMilli() } + runCatching { + val d = java.time.LocalDate.parse(value) + return d.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() + } + runCatching { + val dt = java.time.LocalDateTime.parse(value) + return dt.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() + } + return null +} + +private fun parseCsvLine(line: String): List { + val out = mutableListOf() + val sb = StringBuilder() + var inQuotes = false + var i = 0 + while (i < line.length) { + val ch = line[i] + when { + ch == '"' -> { + if (inQuotes && i + 1 < line.length && line[i + 1] == '"') { + sb.append('"') + i++ + } else { + inQuotes = !inQuotes + } + } + ch == ',' && !inQuotes -> { + out += sb.toString().trim() + sb.clear() + } + else -> sb.append(ch) + } + i++ + } + out += sb.toString().trim() + return out +} + +private fun JSONArray.collectIds(field: String): Set { + val out = mutableSetOf() + for (i in 0 until length()) { + val o = optJSONObject(i) ?: continue + val id = o.optString(field) + if (id.isNotBlank()) out += id + } + return out +} + +private fun JSONArray.countMissingRef(refField: String, validIds: Set): Int { + var count = 0 + for (i in 0 until length()) { + val o = optJSONObject(i) ?: continue + val ref = o.optString(refField) + if (ref.isNotBlank() && ref !in validIds) count++ + } + return count +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/settings/ExerciseLibraryScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/settings/ExerciseLibraryScreen.kt new file mode 100644 index 0000000..c0aac63 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/settings/ExerciseLibraryScreen.kt @@ -0,0 +1,635 @@ +package com.ironlog.app.ui.screens.settings + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.FlowRowScope +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material.icons.outlined.Star +import androidx.compose.material.icons.outlined.TrendingUp +import androidx.compose.material3.* +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.text.TextStyle +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.data.model.LegacyExerciseShape +import com.ironlog.app.data.repository.ExerciseRepository +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.util.buildFilterChipOptions +import com.ironlog.app.util.matchesExerciseFilter +import com.ironlog.app.util.normalizeExerciseNameKey +import com.ironlog.app.util.queryExerciseSearch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject + +private val EQUIP_TAGS = setOf("Barbell", "Dumbbell", "Cable", "Machine", "Bodyweight", "Band", "Kettlebell", "Other") + +@Composable +fun ExerciseLibraryScreen( + repo: ExerciseRepository = ExerciseRepository(), + history: List = emptyList(), + onBack: () -> Unit = {}, + onCreateExercise: ((String) -> Unit)? = null, + onExerciseClick: ((LegacyExerciseShape) -> Unit)? = null, + onOpenExerciseProgress: ((String) -> Unit)? = null, +) { + val c = useTheme() + val context = LocalContext.current + + // Fix: use applicationContext for repo so seeding works + val repoWithCtx = remember { ExerciseRepository(context.applicationContext) } + + var exercises by remember { mutableStateOf>(emptyList()) } + var search by remember { mutableStateOf("") } + var debouncedSearch by remember { mutableStateOf("") } + var muscle by remember { mutableStateOf(null) } + var cat by remember { mutableStateOf(null) } + var equip by remember { mutableStateOf(null) } + var movement by remember { mutableStateOf(null) } + var difficulty by remember { mutableStateOf(null) } + var bwOnly by remember { mutableStateOf(false) } + var scope by remember { mutableStateOf("all") } + var favoriteIds by remember { mutableStateOf>(emptySet()) } + var videoMap by remember { mutableStateOf>(emptyMap()) } + var confirmDeleteExercise by remember { mutableStateOf(null) } + var activeProfileUnavailableEquipment by remember { mutableStateOf>(emptySet()) } + // FIXED: 27 — filter bottom sheet state + var showFilterSheet by remember { mutableStateOf(false) } + // FIXED: 28 — search focus state + var searchFocused by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + val coroutineScope = rememberCoroutineScope() + val settingsRepo = remember { SettingsRepository() } + + val recentlyUsed = remember(history) { + history.flatMap { entry -> entry.exercises.map { it.name } } + .distinct() + .take(5) + } + + LaunchedEffect(Unit) { + exercises = repoWithCtx.getExercisesSnapshot() + favoriteIds = settingsRepo.loadFavorites() + videoMap = withContext(Dispatchers.IO) { loadExerciseVideoLinks(context) } + // Load unavailable equipment from active gym profile + runCatching { + val activeId = settingsRepo.getString("active_gym_profile_id").orEmpty() + val raw = settingsRepo.getString("gym_profiles_json").orEmpty() + if (raw.isNotBlank() && activeId.isNotBlank()) { + val profiles = org.json.JSONArray(raw) + for (i in 0 until profiles.length()) { + val p = profiles.getJSONObject(i) + if (p.optString("id") == activeId) { + val arr = p.optJSONArray("unavailableEquipment") + if (arr != null) { + val set = mutableSetOf() + for (j in 0 until arr.length()) set.add(arr.getString(j)) + activeProfileUnavailableEquipment = set + } + break + } + } + } + } + } + + LaunchedEffect(search) { + delay(180) + debouncedSearch = search + } + + // Filter chip option sets (derived from full list, not scoped, matching RN) + val muscles = remember(exercises) { + buildFilterChipOptions(exercises, includeCategory = false, includeEquipment = false) + } + val equipmentOptions = remember(exercises) { + buildFilterChipOptions(exercises, includeCategory = false, includeEquipment = true) + .filter { it in EQUIP_TAGS } + } + val categories = remember(exercises) { + exercises.mapNotNull { it.category.takeIf(String::isNotBlank)?.replaceFirstChar { ch -> ch.titlecase() } } + .distinct().sorted() + } + val movementOptions = remember(exercises) { + exercises.mapNotNull { it.movementPattern }.distinct().sorted() + } + val difficultyOptions = remember(exercises) { + listOf("beginner", "intermediate", "advanced", "expert") + .filter { d -> exercises.any { it.difficulty?.lowercase() == d } } + } + + val filtered = remember(exercises, muscle, cat, equip, movement, difficulty, bwOnly, scope, favoriteIds, debouncedSearch, activeProfileUnavailableEquipment) { + val base = exercises.filter { ex -> + (muscle == null || matchesExerciseFilter(ex, muscle)) && + (cat == null || ex.category.equals(cat, ignoreCase = true)) && + (equip == null || ex.equipment == equip) && + (movement == null || ex.movementPattern == movement) && + (difficulty == null || ex.difficulty?.lowercase() == difficulty) && + (!bwOnly || ex.isBodyweight) && + (scope != "favorites" || favoriteIds.contains(ex.id)) && + (scope != "custom" || ex.isCustom) && + // Respect active gym profile: hide exercises requiring unavailable equipment + (activeProfileUnavailableEquipment.isEmpty() || !activeProfileUnavailableEquipment.contains(ex.equipment)) + } + queryExerciseSearch(base, debouncedSearch) + .sortedWith(compareByDescending { favoriteIds.contains(it.id) }.thenBy { it.name }) + } + + val missingExerciseSeed = search.trim() + val hasExactMatch = exercises.any { it.name.equals(missingExerciseSeed, ignoreCase = true) } + + // FIXED: 27 — Hoisted so it's accessible by both the Column and the ModalBottomSheet + val hasActiveFilters = muscle != null || cat != null || equip != null || movement != null || difficulty != null || bwOnly || scope != "all" + + Column(Modifier.fillMaxSize().background(c.bg).statusBarsPadding()) { + + ScreenHeader( + title = "EXERCISE LIBRARY", + onBack = onBack, + subtitle = "${filtered.size} exercise${if (filtered.size == 1) "" else "s"}", + ) + + // FIXED: 27 — Single filter trigger row with active chips + FILTERS button + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LazyRow( + Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (scope == "favorites") item { ActiveFilterChip("★ Favorites") { scope = "all" } } + if (scope == "custom") item { ActiveFilterChip("Custom") { scope = "all" } } + if (cat != null) item { ActiveFilterChip(cat!!) { cat = null } } + if (muscle != null) item { ActiveFilterChip(muscle!!) { muscle = null } } + if (equip != null) item { ActiveFilterChip(equip!!) { equip = null } } + if (movement != null) item { ActiveFilterChip(movement!!) { movement = null } } + if (difficulty != null) item { ActiveFilterChip(difficulty!!) { difficulty = null } } + if (bwOnly) item { ActiveFilterChip("BW Only") { bwOnly = false } } + if (activeProfileUnavailableEquipment.isNotEmpty()) item { + Text("🏋 Gym filtered", color = c.warning, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(vertical = 5.dp)) + } + if (!hasActiveFilters && activeProfileUnavailableEquipment.isEmpty()) item { + Text("All exercises", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, modifier = Modifier.padding(vertical = 5.dp)) + } + } + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(if (hasActiveFilters) c.accentSoft else c.surface) + .border(1.dp, if (hasActiveFilters) c.accentBorder else c.faint, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { showFilterSheet = true } + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon(Icons.Outlined.FilterList, null, tint = if (hasActiveFilters) c.accent else c.muted, modifier = Modifier.size(14.dp)) + Text("FILTERS", color = if (hasActiveFilters) c.accent else c.muted, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight(IronLogType.button.fontWeight)) + } + } + } + + // FIXED: 28 — Custom styled search field + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(c.card) + .border(1.dp, if (searchFocused) c.accent.copy(alpha = 0.6f) else c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(Icons.Filled.Search, null, tint = if (searchFocused) c.accent else c.muted, modifier = Modifier.size(18.dp)) + Box(Modifier.weight(1f)) { + if (search.isEmpty()) { + Text("Search exercises…", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + } + BasicTextField( + value = search, + onValueChange = { search = it }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { searchFocused = it.isFocused }, + textStyle = TextStyle(color = c.text, fontSize = IronLogType.body.fontSize.sp), + singleLine = true, + ) + } + if (search.isNotBlank()) { + Box(Modifier.size(44.dp).clickable { search = "" }, contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Close, null, tint = c.muted, modifier = Modifier.size(16.dp)) + } + } + } + Text( + // FIXED: 1 + "${filtered.size} exercise${if (filtered.size == 1) "" else "s"}", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 6.dp) + ) + + // ── Exercise list ───────────────────────────────────────────────────── + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 100.dp), + ) { + // GAP-16: Recently Used chips — shown only when no search/filters are active + if (recentlyUsed.isNotEmpty() && debouncedSearch.isBlank() && muscle == null && cat == null && equip == null && movement == null && difficulty == null && !bwOnly && scope == "all") { + item { + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + "RECENTLY USED", + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(recentlyUsed) { name -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.surface) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { + search = name + } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text(name, color = c.text, fontSize = IronLogType.body.fontSize.sp) + } + } + } + } + } + } + + if (filtered.isEmpty()) { + item { + Column( + Modifier.fillMaxWidth().padding(40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("No exercises found", color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.section.fontSize.sp) + Text("Try a different filter or search term.", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + if (missingExerciseSeed.isNotBlank() && !hasExactMatch) { + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(c.accentSoft) + .border(1.dp, c.accentBorder, RoundedCornerShape(10.dp)) + .clickable { onCreateExercise?.invoke(missingExerciseSeed) } + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { Text("+ Add Exercise", color = c.accent, fontWeight = FontWeight.Bold, fontSize = IronLogType.body.fontSize.sp) } + } + } + } + } + } + + items(filtered, key = { it.id }) { ex -> + val hasVideo = videoMap.containsKey(normalizeExerciseNameKey(ex.name)) + ExerciseRow( + exercise = ex, + isFavorite = favoriteIds.contains(ex.id), + onFavorite = { + favoriteIds = if (favoriteIds.contains(ex.id)) favoriteIds - ex.id else favoriteIds + ex.id + coroutineScope.launch { settingsRepo.saveFavorites(favoriteIds) } + }, + onClick = { onExerciseClick?.invoke(ex) }, + hasVideo = hasVideo, + onVideo = { + val link = videoMap[normalizeExerciseNameKey(ex.name)] ?: return@ExerciseRow + runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + }, + onDeleteCustom = if (ex.isCustom) ({ confirmDeleteExercise = ex }) else null, + // GAP-09: progress icon — uses dedicated callback if provided, else falls through to onExerciseClick + onOpenProgress = onOpenExerciseProgress?.let { cb -> { cb(ex.name) } }, + ) + } + + // Footer: "Can't find it?" if searching + if (missingExerciseSeed.isNotBlank() && !hasExactMatch && filtered.isNotEmpty()) { + item { + Column( + Modifier.fillMaxWidth().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Can't find it?", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + Box( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(c.accentSoft) + .border(1.dp, c.accentBorder, RoundedCornerShape(10.dp)) + .clickable { onCreateExercise?.invoke(missingExerciseSeed) } + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { Text("+ Add Exercise", color = c.accent, fontWeight = FontWeight.Bold, fontSize = IronLogType.body.fontSize.sp) } + } + } + } + } + } + + // FIXED: 27 — Filter bottom sheet + if (showFilterSheet) { + @OptIn(ExperimentalMaterial3Api::class) + ModalBottomSheet( + onDismissRequest = { showFilterSheet = false }, + containerColor = c.card, + ) { + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("FILTERS", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), letterSpacing = IronLogType.eyebrow.letterSpacing.sp) + // Scope + Text("Scope", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + @OptIn(ExperimentalLayoutApi::class) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + ExerciseFilterChip("All", scope == "all" && !bwOnly, onClick = { scope = "all"; bwOnly = false }) + ExerciseFilterChip("★ Favorites", scope == "favorites", onClick = { scope = if (scope == "favorites") "all" else "favorites" }) + ExerciseFilterChip("Custom", scope == "custom", onClick = { scope = if (scope == "custom") "all" else "custom" }) + ExerciseFilterChip("BW Only", bwOnly, onClick = { bwOnly = !bwOnly }) + } + // Muscle + if (muscles.isNotEmpty()) { + Text("Muscle Group", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + @OptIn(ExperimentalLayoutApi::class) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + muscles.forEach { m -> ExerciseFilterChip(m, muscle == m) { muscle = if (muscle == m) null else m } } + } + } + // Equipment + if (equipmentOptions.isNotEmpty()) { + Text("Equipment", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + @OptIn(ExperimentalLayoutApi::class) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + equipmentOptions.forEach { e -> ExerciseFilterChip(e, equip == e) { equip = if (equip == e) null else e } } + } + } + // Category + if (categories.isNotEmpty()) { + Text("Category", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + @OptIn(ExperimentalLayoutApi::class) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + categories.forEach { catOpt -> ExerciseFilterChip(catOpt, cat == catOpt) { cat = if (cat == catOpt) null else catOpt } } + } + } + // Movement + if (movementOptions.isNotEmpty()) { + Text("Movement Pattern", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + @OptIn(ExperimentalLayoutApi::class) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + movementOptions.forEach { m -> ExerciseFilterChip(m, movement == m) { movement = if (movement == m) null else m } } + } + } + // Difficulty + if (difficultyOptions.isNotEmpty()) { + Text("Difficulty", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + @OptIn(ExperimentalLayoutApi::class) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + difficultyOptions.forEach { d -> ExerciseFilterChip(d.replaceFirstChar { it.titlecase() }, difficulty == d) { difficulty = if (difficulty == d) null else d } } + } + } + // Clear all + if (hasActiveFilters) { + Box( + Modifier.fillMaxWidth().clip(RoundedCornerShape(IronLogRadius.md.dp)) + .border(1.dp, c.faint, RoundedCornerShape(IronLogRadius.md.dp)) + .clickable { + scope = "all"; cat = null; muscle = null; equip = null + movement = null; difficulty = null; bwOnly = false + } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { Text("Clear Filters", color = c.muted, fontSize = IronLogType.body.fontSize.sp) } + } + } + } + } + + // Confirm delete dialog for custom exercises + confirmDeleteExercise?.let { ex -> + AlertDialog( + onDismissRequest = { confirmDeleteExercise = null }, + containerColor = c.card, + title = { Text("Delete \"${ex.name}\"?", color = c.text) }, + text = { Text("This will permanently remove this custom exercise.", color = c.muted) }, + confirmButton = { + TextButton(onClick = { + coroutineScope.launch { + repoWithCtx.deleteCustomExercise(ex.id) + exercises = repoWithCtx.getExercisesSnapshot() + confirmDeleteExercise = null + } + }) { + Text("DELETE", color = c.danger, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = { confirmDeleteExercise = null }) { Text("CANCEL", color = c.muted) } + }, + ) + } +} + +// FIXED: 27 — Dismissable active filter chip shown in the trigger row +@Composable +private fun ActiveFilterChip(label: String, onDismiss: () -> Unit) { + val c = useTheme() + Row( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.accentSoft) + .border(1.dp, c.accentBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .padding(start = 10.dp, end = 6.dp, top = 5.dp, bottom = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(label, color = c.accent, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Bold) + Box(Modifier.size(32.dp).clickable(onClick = onDismiss), contentAlignment = Alignment.Center) { + Text("×", color = c.accent, fontSize = IronLogType.body.fontSize.sp, fontWeight = FontWeight.Bold) + } + } +} + +@Composable +private fun FilterRow(borderBottom: Boolean = true, content: @Composable RowScope.() -> Unit) { + val c = useTheme() + LazyRow( + modifier = Modifier + .fillMaxWidth() + .then(if (borderBottom) Modifier.drawBottomBorder(c.faint) else Modifier) + .padding(vertical = 5.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + item { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, content = content) } + } +} + +@Composable +private fun ExerciseFilterChip(label: String, active: Boolean, onClick: () -> Unit) { + val c = useTheme() + Box( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(if (active) c.accentSoft else Color.Transparent) + .border(1.dp, if (active) c.accentBorder else c.faint, RoundedCornerShape(20.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 5.dp) + ) { + Text(label, color = if (active) c.accent else c.muted, fontSize = IronLogType.meta.fontSize.sp, fontWeight = if (active) FontWeight.Bold else FontWeight.Normal) + } +} + +@Composable +private fun ExerciseRow( + exercise: LegacyExerciseShape, + isFavorite: Boolean, + onFavorite: () -> Unit, + onClick: () -> Unit, + hasVideo: Boolean, + onVideo: () -> Unit, + onDeleteCustom: (() -> Unit)? = null, + onOpenProgress: (() -> Unit)? = null, +) { + val c = useTheme() + // FIXED: 29 + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 72.dp) + .clickable(onClick = onClick) + .drawBottomBorder(c.faint) + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // FIXED: 1, 29 + Text(exercise.name, color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.body.fontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + if (exercise.isCustom) { + Box( + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(c.accentSoft) + .border(1.dp, c.accentBorder, RoundedCornerShape(4.dp)) + .padding(horizontal = 5.dp, vertical = 2.dp) + ) { Text("CUSTOM", color = c.accent, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight.Black, letterSpacing = IronLogType.micro.letterSpacing.sp) } + } + } + // FIXED: 29 — combined subtitle: muscle · equipment · difficulty + val subtitle = listOfNotNull( + exercise.primaryMuscle?.takeIf { it.isNotBlank() }, + exercise.equipment?.takeIf { it.isNotBlank() }, + exercise.difficulty?.trim()?.takeIf { it.isNotBlank() }?.replaceFirstChar { it.titlecase() } + ).joinToString(" · ") + if (subtitle.isNotBlank()) { + Text(subtitle, color = c.muted, fontSize = IronLogType.meta.fontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp)) + } + } + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) { + // FIXED: 29 — 44dp touch target + IconButton(onClick = onFavorite, modifier = Modifier.size(44.dp)) { + Icon( + if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, + contentDescription = null, + tint = if (isFavorite) c.accent else c.muted, + modifier = Modifier.size(20.dp) + ) + } + // GAP-09: progress trend icon — navigates to ExerciseProgressScreen + if (onOpenProgress != null) { + IconButton(onClick = onOpenProgress, modifier = Modifier.size(44.dp)) { + Icon( + Icons.Outlined.TrendingUp, + contentDescription = "View progress", + tint = c.muted, + modifier = Modifier.size(18.dp), + ) + } + } + // Custom exercise delete button — 44dp touch target + if (exercise.isCustom && onDeleteCustom != null) { + IconButton(onClick = onDeleteCustom, modifier = Modifier.size(44.dp)) { + Icon( + Icons.Outlined.Delete, + contentDescription = "Delete", + // FIXED: 3 + tint = c.danger, + modifier = Modifier.size(18.dp), + ) + } + } + if (hasVideo) { + Text("▶ VIDEO", color = c.accent, fontWeight = FontWeight.Bold, fontSize = IronLogType.eyebrow.fontSize.sp, + modifier = Modifier.clickable(onClick = onVideo).padding(horizontal = 8.dp, vertical = 10.dp)) + } + } + } +} + +private fun loadExerciseVideoLinks(context: android.content.Context): Map { + return runCatching { + val text = context.assets.open("ironlog/exercise_youtube_by_normalized_name.json").bufferedReader().use { it.readText() } + val root = JSONObject(text) + buildMap { + root.keys().forEach { key -> + val obj = root.optJSONObject(key) ?: return@forEach + val link = obj.optString("youtubeLink").takeIf { it.isNotBlank() } ?: return@forEach + put(key, link) + } + } + }.getOrDefault(emptyMap()) +} + + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/settings/GymProfileEditorScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/settings/GymProfileEditorScreen.kt new file mode 100644 index 0000000..e16c8ea --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/settings/GymProfileEditorScreen.kt @@ -0,0 +1,338 @@ +package com.ironlog.app.ui.screens.settings + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.util.convertKgToUnit +import com.ironlog.app.util.convertUnitToKg +import kotlinx.coroutines.launch +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import java.util.UUID +import kotlin.math.abs + +val PRESET_PLATE_COLORS = listOf( + "#D32F2F" to "Red", + "#1565C0" to "Blue", + "#F9A825" to "Yellow", + "#2E7D32" to "Green", + "#F5F5F5" to "White", + "#212121" to "Black", +) + +private fun plateColorFromHex(hex: String): Color { + if (hex.isBlank()) return Color(0xFFBDBDBD) + return try { Color(android.graphics.Color.parseColor(hex)) } catch (_: Exception) { Color(0xFFBDBDBD) } +} + +@Composable +fun GymProfileEditorScreen( + profileId: String = "", + onSaved: () -> Unit = {}, + onBack: () -> Unit = {}, + repo: SettingsRepository = SettingsRepository(), +) { + val c = useTheme() + val scope = rememberCoroutineScope() + val json = remember { Json { ignoreUnknownKeys = true } } + + var unit by remember { mutableStateOf("kg") } + val unitLabel = if (unit == "lbs") "lb" else "kg" + var haptic by remember { mutableStateOf(true) } + + var name by remember { mutableStateOf("") } + var barWeight by remember { mutableStateOf("") } + var status by remember { mutableStateOf("") } + + var plates by remember { mutableStateOf>(DEFAULT_PLATES) } + var newPlate by remember { mutableStateOf("") } + var unavailableEquipment by remember { mutableStateOf>(emptySet()) } + var colorPickerTarget by remember { mutableStateOf(null) } + + LaunchedEffect(profileId) { + // Read haptic + unit from the ironlog_settings JSON blob (canonical location). + val settingsJson = runCatching { + repo.getString("ironlog_settings")?.let { org.json.JSONObject(it) } + }.getOrNull() + haptic = settingsJson?.optBoolean("hapticFeedback", true) ?: true + unit = settingsJson?.optString("weightUnit", "kg")?.takeIf { it.isNotBlank() } ?: "kg" + val raw = repo.getString("gym_profiles_json").orEmpty() + val existing = runCatching { if (raw.isBlank()) emptyList() else json.decodeFromString>(raw) }.getOrDefault(emptyList()) + val hit = existing.firstOrNull { it.id == profileId } + if (hit != null) { + name = hit.name + barWeight = convertKgToUnit(hit.barWeightKg, unit, 2).toString() + if (hit.plates.isNotEmpty()) plates = hit.plates + unavailableEquipment = hit.unavailableEquipment.toSet() + } else { + barWeight = if (unit == "lbs") "45" else "20" + } + } + + val adjustQty: (Double, Int) -> Unit = { w, delta -> + plates = plates.map { if (it.weightKg == w) it.copy(quantity = (it.quantity + delta).coerceAtLeast(1)) else it } + } + + val removePlate: (Double) -> Unit = { w -> + plates = plates.filter { it.weightKg != w } + } + + fun addPlate() { + val typedWeight = newPlate.toDoubleOrNull() + if (typedWeight == null || typedWeight <= 0) { + status = "Enter a positive plate weight ($unitLabel)." + return + } + val weightKg = convertUnitToKg(typedWeight, unit) + val exists = plates.any { abs(it.weightKg - weightKg) < 0.001 } + if (exists) { + status = "That plate weight is already in the list." + return + } + plates = (plates + PlateDto(weightKg, 2)).sortedByDescending { it.weightKg } + newPlate = "" + status = "" + } + + LazyColumn( + modifier = Modifier.fillMaxSize().background(c.bg).statusBarsPadding().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { ScreenHeader(title = if (profileId.isBlank()) "NEW GYM PROFILE" else "EDIT GYM PROFILE", onBack = onBack) } + item { + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = BorderStroke(1.dp, c.cardBorder), + ) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("PROFILE NAME", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 3.sp) + OutlinedTextField( + value = name, + onValueChange = { if (it.length <= 60) name = it }, + placeholder = { Text("e.g. Home Gym, Planet Fitness") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + } + } + + item { + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = BorderStroke(1.dp, c.cardBorder), + ) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("BAR WEIGHT ($unitLabel)", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 3.sp) + OutlinedTextField( + value = barWeight, + onValueChange = { barWeight = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + item { + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = BorderStroke(1.dp, c.cardBorder), + ) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("AVAILABLE PLATES", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 3.sp) + Text("Quantity = pairs available per side", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, modifier = Modifier.padding(bottom = 8.dp)) + + plates.forEach { plate -> + Column(Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "${convertKgToUnit(plate.weightKg, unit, 2)}$unitLabel", + color = c.text, + fontSize = IronLogType.section.fontSize.sp, + fontWeight = FontWeight.Bold + ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + Modifier.size(44.dp).clickable { colorPickerTarget = if (colorPickerTarget == plate.weightKg) null else plate.weightKg }, + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(24.dp) + .background(plateColorFromHex(plate.color), CircleShape) + .border(1.dp, c.cardBorder, CircleShape) + ) + } + IconButton(onClick = { adjustQty(plate.weightKg, -1) }) { Icon(Icons.Filled.Remove, null, tint = c.muted) } + Text("${plate.quantity}", color = c.text, fontSize = IronLogType.section.fontSize.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(horizontal = 4.dp)) + IconButton(onClick = { adjustQty(plate.weightKg, 1) }) { Icon(Icons.Filled.Add, null, tint = c.muted) } + IconButton(onClick = { removePlate(plate.weightKg) }) { Icon(Icons.Filled.Close, null, tint = c.danger) } + } + } + if (colorPickerTarget == plate.weightKg) { + Row( + Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PRESET_PLATE_COLORS.forEach { (hexColor, colorName) -> + Box( + Modifier.size(44.dp).clickable { + plates = plates.map { if (it.weightKg == plate.weightKg) it.copy(color = hexColor) else it } + colorPickerTarget = null + }, + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(28.dp) + .background(plateColorFromHex(hexColor), CircleShape) + .border( + if (plate.color == hexColor) 2.dp else 1.dp, + if (plate.color == hexColor) c.accent else c.cardBorder, + CircleShape + ) + ) + } + } + } + } + } + } + + Row(Modifier.fillMaxWidth().padding(top = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = newPlate, + onValueChange = { newPlate = it }, + placeholder = { Text("Add plate weight ($unitLabel)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f) + ) + Button(onClick = { addPlate() }, colors = ButtonDefaults.buttonColors(containerColor = c.accentSoft, contentColor = c.accent)) { + Icon(Icons.Filled.Add, contentDescription = "Add plate") + } + } + } + } + } + + // ── Unavailable Equipment ───────────────────────────────────────────── + item { + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = BorderStroke(1.dp, c.cardBorder), + ) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("UNAVAILABLE EQUIPMENT", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = IronLogType.eyebrow.letterSpacing.sp) + Text("Check equipment types not available at this gym. These will be filtered from your exercise library when this profile is active.", color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + val equipmentOptions = listOf("Barbell", "Dumbbell", "Cable", "Machine", "Kettlebell", "Band") + equipmentOptions.forEach { equip -> + val checked = unavailableEquipment.contains(equip) + Row( + Modifier.fillMaxWidth().clickable { + unavailableEquipment = if (checked) unavailableEquipment - equip else unavailableEquipment + equip + }.padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Checkbox(checked = checked, onCheckedChange = { + unavailableEquipment = if (checked) unavailableEquipment - equip else unavailableEquipment + equip + }) + Text(equip, color = c.text, fontSize = IronLogType.body.fontSize.sp) + if (checked) Text("(unavailable)", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + } + } + + item { + Button( + onClick = { + scope.launch { + val bwParsed = barWeight.toDoubleOrNull() + if (name.isBlank() || bwParsed == null || bwParsed <= 0) { + status = "Enter valid name and bar weight." + return@launch + } + val bwKg = convertUnitToKg(bwParsed, unit) + val raw = runCatching { repo.getString("gym_profiles_json").orEmpty() }.getOrDefault("") + val all = runCatching { if (raw.isBlank()) emptyList() else json.decodeFromString>(raw) }.getOrDefault(emptyList()).toMutableList() + val id = if (profileId.isBlank()) UUID.randomUUID().toString() else profileId + val updated = GymProfileDto(id = id, name = name.trim(), barWeightKg = bwKg, plates = plates, unavailableEquipment = unavailableEquipment.toList()) + val idx = all.indexOfFirst { it.id == id } + if (idx >= 0) all[idx] = updated else all.add(updated) + repo.setString("gym_profiles_json", json.encodeToString(ListSerializer(GymProfileDto.serializer()), all), "json") + if (repo.getString("active_gym_profile_id").isNullOrBlank()) repo.setString("active_gym_profile_id", id) + status = "Profile saved." + onSaved() + } + }, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + colors = ButtonDefaults.buttonColors(containerColor = c.accent) + ) { + Text("SAVE PROFILE", color = c.bg, fontWeight = FontWeight.Black, letterSpacing = 3.sp, modifier = Modifier.padding(vertical = 8.dp)) + } + if (status.isNotBlank()) Text(status, color = c.danger, fontSize = IronLogType.meta.fontSize.sp) + } + + item { Spacer(Modifier.navigationBarsPadding()) } + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/settings/GymProfilesScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/settings/GymProfilesScreen.kt new file mode 100644 index 0000000..db671ca --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/settings/GymProfilesScreen.kt @@ -0,0 +1,251 @@ +package com.ironlog.app.ui.screens.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlinx.coroutines.launch +import androidx.compose.runtime.rememberCoroutineScope +import java.util.UUID + +@Serializable +data class PlateDto( + val weightKg: Double, + val quantity: Int, + val color: String = "" +) + +@Serializable +data class GymProfileDto( + val id: String, + val name: String, + val barWeightKg: Double, + val plates: List = emptyList(), + val unavailableEquipment: List = emptyList(), +) + +val DEFAULT_PLATES = listOf(20.0, 15.0, 10.0, 5.0, 2.5, 1.25).map { PlateDto(it, 2) } + +@Composable +fun GymProfilesScreen( + onBack: () -> Unit = {}, + onCreate: () -> Unit = {}, + onEdit: (String) -> Unit = {}, + repo: SettingsRepository = SettingsRepository(), +) { + val c = useTheme() + val scope = rememberCoroutineScope() + val json = remember { Json { ignoreUnknownKeys = true } } + var profiles by remember { mutableStateOf>(emptyList()) } + var activeId by remember { mutableStateOf(null) } + var deleteTarget by remember { mutableStateOf(null) } + + suspend fun save(updated: List) { + repo.setString("gym_profiles_json", json.encodeToString(ListSerializer(GymProfileDto.serializer()), updated), "json") + profiles = updated + } + + suspend fun load() { + val raw = repo.getString("gym_profiles_json").orEmpty() + profiles = runCatching { if (raw.isBlank()) emptyList() else json.decodeFromString>(raw) }.getOrDefault(emptyList()) + activeId = repo.getString("active_gym_profile_id") + } + + LaunchedEffect(Unit) { load() } + + LazyColumn( + modifier = Modifier.fillMaxSize().background(c.bg).statusBarsPadding().padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 16.dp, bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { ScreenHeader(title = "GYM PROFILES", onBack = onBack) } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onCreate) { Text("Add Profile") } + if (profiles.isEmpty()) { + Button(onClick = { + scope.launch { + val seed = listOf( + GymProfileDto( + id = UUID.randomUUID().toString(), + name = "Default Gym", + barWeightKg = 20.0, + plates = DEFAULT_PLATES + ) + ) + save(seed) + repo.setString("active_gym_profile_id", seed.first().id) + activeId = seed.first().id + } + }) { Text("Seed Default") } + } + } + } + if (profiles.isEmpty()) { + item { Text("No gym profiles yet.", color = c.muted) } + } else { + items(profiles, key = { it.id }) { profile -> + val isActive = profile.id == activeId + val platesSummary = profile.plates.filter { it.quantity > 0 } + .sortedByDescending { it.weightKg } + .take(5) + .joinToString(" ") { "${it.weightKg}×${it.quantity}" } + val unavailableSummary = if (profile.unavailableEquipment.isEmpty()) null + else "No: ${profile.unavailableEquipment.joinToString(", ")}" + + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, if (isActive) c.accentBorder else c.cardBorder), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(profile.name, color = c.text, fontSize = IronLogType.section.fontSize.sp) + if (isActive) { + androidx.compose.foundation.layout.Box( + Modifier + .background(c.accentSoft, RoundedCornerShape(IronLogRadius.full.dp)) + .border(1.dp, c.accentBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + Text("ACTIVE", color = c.accent, fontSize = 9.sp, letterSpacing = 1.sp) + } + } + } + Text("Bar ${profile.barWeightKg} kg", color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + if (platesSummary.isNotEmpty()) { + Text("Plates: $platesSummary", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + if (unavailableSummary != null) { + Text(unavailableSummary, color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + Spacer(Modifier.height(2.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (!isActive) { + Text( + "Set Active", + color = c.accent, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier + .clickable { + scope.launch { + repo.setString("active_gym_profile_id", profile.id) + activeId = profile.id + } + } + .padding(vertical = 10.dp), + ) + } + Text( + "Edit", + color = c.accent, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier + .clickable { onEdit(profile.id) } + .padding(vertical = 10.dp), + ) + Text( + "Duplicate", + color = c.accent, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier + .clickable { + scope.launch { + val copy = profile.copy( + id = UUID.randomUUID().toString(), + name = "${profile.name} (Copy)", + ) + save(profiles + copy) + } + } + .padding(vertical = 10.dp), + ) + Text( + "Delete", + color = c.danger, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier + .clickable { deleteTarget = profile } + .padding(vertical = 10.dp), + ) + } + } + } + } + } + } + + deleteTarget?.let { target -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + containerColor = c.card, + title = { Text("Delete Profile?", color = c.text) }, + text = { Text("\"${target.name}\" will be permanently deleted.", color = c.subtext) }, + confirmButton = { + Button( + onClick = { + scope.launch { + val updated = profiles.filter { it.id != target.id } + save(updated) + if (activeId == target.id) { + val newActive = updated.firstOrNull()?.id + if (newActive != null) repo.setString("active_gym_profile_id", newActive) + else repo.removeSetting("active_gym_profile_id") + activeId = newActive + } + deleteTarget = null + } + }, + colors = ButtonDefaults.buttonColors(containerColor = c.danger), + ) { Text("DELETE", color = c.textOnAccent) } + }, + dismissButton = { + TextButton(onClick = { deleteTarget = null }) { Text("CANCEL", color = c.muted) } + }, + ) + } +} + + + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/settings/HealthConnectPermissionSheet.kt b/app/src/main/java/com/ironlog/app/ui/screens/settings/HealthConnectPermissionSheet.kt new file mode 100644 index 0000000..fbe4b81 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/settings/HealthConnectPermissionSheet.kt @@ -0,0 +1,85 @@ +// app/src/main/java/com/ironlog/app/ui/screens/HealthConnectPermissionSheet.kt +package com.ironlog.app.ui.screens.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HealthConnectPermissionSheet( + onRequestPermissions: () -> Unit, + onDismiss: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "Connect to Health Connect", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + "IronLog can read your sleep, heart rate, and HRV data to give you smarter recovery recommendations.", + style = MaterialTheme.typography.bodyMedium, + ) + + BenefitRow(emoji = "😴", text = "Sleep quality → better fatigue scoring") + BenefitRow(emoji = "❤️", text = "Resting HR + HRV → recovery readiness") + BenefitRow(emoji = "🏋️", text = "Workouts written back → unified health timeline") + + Text( + "Your data stays on your device. IronLog never uploads health data to any server.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(8.dp)) + + Button( + onClick = onRequestPermissions, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Connect Health Connect") + } + + TextButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Not now") + } + } + } +} + +@Composable +private fun BenefitRow(emoji: String, text: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(emoji, style = MaterialTheme.typography.bodyLarge) + Spacer(Modifier.width(10.dp)) + Text(text, style = MaterialTheme.typography.bodyMedium) + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/settings/HealthConnectScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/settings/HealthConnectScreen.kt new file mode 100644 index 0000000..696c3c8 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/settings/HealthConnectScreen.kt @@ -0,0 +1,313 @@ +package com.ironlog.app.ui.screens.settings + +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.outlined.Favorite +import androidx.compose.material.icons.outlined.HealthAndSafety +import androidx.compose.material.icons.outlined.Hotel +import androidx.compose.material.icons.outlined.MonitorHeart +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.health.connect.client.PermissionController +import com.ironlog.app.data.health.BiometricSnapshot +import com.ironlog.app.data.health.HealthConnectRepository +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun HealthConnectScreen( + onBack: () -> Unit, +) { + val c = useTheme() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val repo = remember { HealthConnectRepository(context) } + + var isAvailable by remember { mutableStateOf(repo.isAvailable()) } + var grantedCount by remember { mutableStateOf(0) } + var hasAllPermissions by remember { mutableStateOf(false) } + var snapshot by remember { mutableStateOf(BiometricSnapshot()) } + var statusText by remember { mutableStateOf("Checking Health Connect...") } + var loading by remember { mutableStateOf(true) } + + fun refresh() { + scope.launch { + loading = true + isAvailable = repo.isAvailable() + if (!isAvailable) { + grantedCount = 0 + hasAllPermissions = false + snapshot = BiometricSnapshot() + statusText = "Health Connect is not available on this device yet." + loading = false + return@launch + } + val granted = withContext(Dispatchers.IO) { repo.grantedPermissions() } + grantedCount = granted.size + hasAllPermissions = granted.containsAll(repo.requiredPermissions) + val canReadRecovery = granted.containsAll(repo.readPermissions) + snapshot = if (canReadRecovery) { + withContext(Dispatchers.IO) { repo.readBiometricSnapshot() } + } else { + BiometricSnapshot() + } + statusText = when { + hasAllPermissions -> "Connected. Ironlog can enrich recovery and sync workouts and body weight." + canReadRecovery -> "Recovery signals are connected. Grant write access to sync workouts and body weight." + else -> "Permissions needed before Ironlog can read recovery signals." + } + loading = false + } + } + + val permissionLauncher = rememberLauncherForActivityResult( + PermissionController.createRequestPermissionResultContract() + ) { granted -> + grantedCount = granted.size + hasAllPermissions = granted.containsAll(repo.requiredPermissions) + refresh() + } + + LaunchedEffect(Unit) { refresh() } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(c.bg) + .statusBarsPadding(), + contentPadding = PaddingValues(start = 18.dp, end = 18.dp, top = 12.dp, bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = c.text) + } + Column(Modifier.weight(1f)) { + Text( + "Health Connect", + color = c.text, + fontWeight = FontWeight(IronLogType.title.fontWeight), + fontSize = IronLogType.title.fontSize.sp, + ) + Text( + "Recovery signals, workout sync, and body-weight writes.", + color = c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } + } + + item { + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = BorderStroke(1.dp, if (hasAllPermissions) c.success.copy(alpha = 0.55f) else c.cardBorder), + shape = RoundedCornerShape(IronLogRadius.xl.dp), + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + androidx.compose.foundation.layout.Box( + modifier = Modifier + .size(46.dp) + .clip(CircleShape) + .background(if (hasAllPermissions) c.success.copy(alpha = 0.16f) else c.accent.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.HealthAndSafety, + contentDescription = null, + tint = if (hasAllPermissions) c.success else c.accent, + ) + } + Column(Modifier.weight(1f)) { + Text( + if (hasAllPermissions) "Connected" else if (isAvailable) "Ready to connect" else "Unavailable", + color = c.text, + fontWeight = FontWeight.ExtraBold, + fontSize = IronLogType.section.fontSize.sp, + ) + Text(statusText, color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + } + } + + Text( + "$grantedCount / ${repo.requiredPermissions.size} permissions granted", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + + Button( + onClick = { permissionLauncher.launch(repo.requiredPermissions) }, + enabled = isAvailable && !loading, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (hasAllPermissions) "Review Permissions" else "Connect Health Connect") + } + + OutlinedButton( + onClick = { refresh() }, + enabled = !loading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Refresh Signals") + } + } + } + } + + item { + Text( + "LATEST SIGNALS", + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + letterSpacing = 3.sp, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } + + item { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + SignalRow( + label = "Sleep", + value = snapshot.sleepHours?.let { String.format(java.util.Locale.US, "%.1fh", it) } ?: "--", + helper = "Last 36 hours", + icon = Icons.Outlined.Hotel, + ) + SignalRow( + label = "Resting HR", + value = snapshot.restingHrBpm?.let { "$it bpm" } ?: "--", + helper = "Lower is usually better recovered", + icon = Icons.Outlined.Favorite, + ) + SignalRow( + label = "HRV", + value = snapshot.hrvRmssd?.let { String.format(java.util.Locale.US, "%.0f ms", it) } ?: "--", + helper = "Used as a recovery confidence signal", + icon = Icons.Outlined.MonitorHeart, + ) + } + } + + item { + SettingsOpenRow( + label = "Open Android Health Connect settings", + onClick = { + val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + "android.health.connect.action.HEALTH_HOME_SETTINGS" + } else { + "androidx.health.ACTION_HEALTH_CONNECT_SETTINGS" + } + val intent = Intent(action) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { context.startActivity(intent) } + .onFailure { + context.startActivity( + Intent(Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + }, + ) + } + + item { Spacer(Modifier.height(12.dp)) } + } +} + +@Composable +private fun SignalRow( + label: String, + value: String, + helper: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, +) { + val c = useTheme() + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(c.card) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(icon, contentDescription = null, tint = c.accent, modifier = Modifier.size(22.dp)) + Column(Modifier.weight(1f)) { + Text(label, color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.body.fontSize.sp) + Text(helper, color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + Text(value, color = c.text, fontWeight = FontWeight.ExtraBold, fontSize = IronLogType.section.fontSize.sp) + } +} + +@Composable +private fun SettingsOpenRow(label: String, onClick: () -> Unit) { + val c = useTheme() + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(c.card) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, color = c.text, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Icon(Icons.Filled.ChevronRight, contentDescription = null, tint = c.muted) + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/settings/ImportCenterScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/settings/ImportCenterScreen.kt new file mode 100644 index 0000000..b259b30 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/settings/ImportCenterScreen.kt @@ -0,0 +1,208 @@ +package com.ironlog.app.ui.screens.settings + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.foundation.BorderStroke +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.data.repository.ImportExportRepository +import com.ironlog.app.data.repository.WorkoutRepository +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val IMPORT_SOURCES = listOf( + Triple("ironlog_json", "IronLog Backup", arrayOf("application/json", "text/plain", "*/*")), + Triple("strong_csv", "Strong CSV", arrayOf("text/comma-separated-values", "text/csv", "text/plain", "*/*")), + Triple("hevy_csv", "Hevy CSV", arrayOf("text/comma-separated-values", "text/csv", "text/plain", "*/*")), + Triple("openweight_json", "OpenWeight JSON", arrayOf("application/json", "text/plain", "*/*")), +) + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ImportCenterScreen( + onBack: () -> Unit = {}, + onOpenDataPortability: (String) -> Unit = {}, + onRestoreComplete: () -> Unit = {}, +) { + val c = useTheme() + val ctx = LocalContext.current + val scope = rememberCoroutineScope() + + var selected by remember { mutableStateOf("ironlog_json") } + var pickedText by remember { mutableStateOf("") } + var preview by remember { mutableStateOf(null) } + var status by remember { mutableStateOf("") } + var isWorking by remember { mutableStateOf(false) } + var showConfirm by remember { mutableStateOf(false) } + + val repo = remember { WorkoutRepository() } + val importExportRepo = remember { ImportExportRepository() } + + val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch(Dispatchers.IO) { + val text = runCatching { + ctx.contentResolver.openInputStream(uri)?.bufferedReader()?.readText().orEmpty() + }.getOrElse { e -> + withContext(Dispatchers.Main) { status = "Could not read file: ${e.message}" } + return@launch + } + val pv = buildImportPreview(text, selected) + withContext(Dispatchers.Main) { pickedText = text; preview = pv; status = "" } + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize().background(c.bg).statusBarsPadding(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { ScreenHeader(title = "IMPORT", onBack = onBack) } + + item { + Text("Choose your source format, then pick the file from your device. A preview appears before anything is written.", + color = c.subtext, fontSize = IronLogType.body.fontSize.sp) + } + + // Source format selector + item { + Text("SOURCE FORMAT", color = c.accent, fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), letterSpacing = IronLogType.eyebrow.letterSpacing.sp) + } + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + IMPORT_SOURCES.forEach { (id, label, _) -> + val sel = selected == id + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(if (sel) c.accentSoft else c.card) + .border(1.dp, if (sel) c.accentBorder else c.cardBorder, RoundedCornerShape(10.dp)) + .clickable { selected = id; preview = null; pickedText = "" } + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text(label, color = if (sel) c.accent else c.text, fontWeight = if (sel) FontWeight.Bold else FontWeight.Normal, + fontSize = IronLogType.body.fontSize.sp) + } + } + } + } + + // Pick file button + item { + Button( + onClick = { + val mime = IMPORT_SOURCES.firstOrNull { it.first == selected }?.third + ?: arrayOf("*/*") + filePicker.launch(mime) + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isWorking, + ) { Text("Pick File to Import") } + } + + // Preview card + preview?.let { pv -> + item { + Card(colors = CardDefaults.cardColors(containerColor = c.card), border = BorderStroke(1.dp, c.cardBorder)) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Preview", color = c.text, fontSize = IronLogType.section.fontSize.sp, fontWeight = FontWeight.Bold) + Text("${pv.validRows} rows ready to import", color = c.subtext) + if (pv.domainCounts.isNotEmpty()) + Text(pv.domainCounts.entries.joinToString(" · ") { "${it.key}: ${it.value}" }, + color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + pv.warnings.take(3).forEach { Text("⚠ $it", color = c.warning, fontSize = IronLogType.meta.fontSize.sp) } + pv.samples.forEach { Text(it, color = c.muted, fontSize = IronLogType.meta.fontSize.sp) } + if (pv.error != null) Text("Error: ${pv.error}", color = c.danger, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + item { + Button( + onClick = { showConfirm = true }, + modifier = Modifier.fillMaxWidth(), + enabled = pv.canImport && !isWorking, + ) { Text(if (selected == "ironlog_json") "Restore ${pv.validRows} rows" else "Import ${pv.validRows} rows") } + } + } + + if (status.isNotBlank()) item { Text(status, color = c.accent) } + } + + if (showConfirm) { + AlertDialog( + onDismissRequest = { showConfirm = false }, + title = { Text(if (selected == "ironlog_json") "Restore backup?" else "Import data?") }, + text = { + Text( + if (selected == "ironlog_json") { + "${preview?.validRows ?: 0} backup rows will be restored. Matching rows are updated by stable ID, so seeded exercises and repeated restores will not create duplicates." + } else { + "${preview?.validRows ?: 0} rows will be added. Importing the same file twice may create duplicates." + } + ) + }, + confirmButton = { + TextButton(onClick = { + showConfirm = false + isWorking = true + val pv = preview ?: return@TextButton + val text = pickedText + val src = selected + scope.launch(Dispatchers.IO) { + val replaceMode = src == "ironlog_json" + val res = runCatching { importText(text, repo, importExportRepo, pv, src, replaceMode) } + withContext(Dispatchers.Main) { + status = res.fold( + { + if (src == "ironlog_json") onRestoreComplete() + if (src == "ironlog_json") "Restored $it rows." else "Imported $it rows." + }, + { "Failed: ${it.message}" }, + ) + isWorking = false; preview = null; pickedText = "" + } + } + }) { Text(if (selected == "ironlog_json") "Restore" else "Import") } + }, + dismissButton = { TextButton(onClick = { showConfirm = false }) { Text("Cancel") } }, + ) + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/settings/PrivacyScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/settings/PrivacyScreen.kt new file mode 100644 index 0000000..50bf78f --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/settings/PrivacyScreen.kt @@ -0,0 +1,39 @@ +package com.ironlog.app.ui.screens.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogType + +private val PRIVACY_POINTS = listOf( + "Your workout data stays local on this device unless you explicitly export or import a backup file.", + "Plain JSON exports include structured app data. Progress photos and custom media stay local in this release.", + "Advanced encrypted snapshots are protected by your passphrase before they leave the app.", + "Google Drive sync is disabled in this build, so cloud storage is only used when you manually share an export there.", + "IronLog never silently restores over live data. Every restore shows a preview first and creates a rollback snapshot when possible.", +) + +@Composable +fun PrivacyScreen(onBack: () -> Unit = {}) { + val colors = useTheme() + Column(Modifier.fillMaxSize().background(colors.bg).statusBarsPadding().verticalScroll(rememberScrollState()).padding(20.dp).navigationBarsPadding(), verticalArrangement = Arrangement.spacedBy(16.dp)) { + ScreenHeader(title = "PRIVACY", onBack = onBack) + Column(Modifier.background(colors.card, RoundedCornerShape(14.dp)).border(1.dp, colors.cardBorder, RoundedCornerShape(14.dp)).padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("LOCAL-FIRST BY DEFAULT", color = colors.text, fontSize = IronLogType.title.fontSize.sp) + Text("IronLog stores your training history locally first. Export and restore are explicit actions, so your data never moves unless you choose it.", color = colors.subtext, fontSize = IronLogType.body.fontSize.sp, lineHeight = 20.sp) + } + PRIVACY_POINTS.forEach { point -> Text(point, color = colors.text, fontSize = IronLogType.body.fontSize.sp, lineHeight = 20.sp, modifier = Modifier.fillMaxWidth().background(colors.card, RoundedCornerShape(10.dp)).border(1.dp, colors.cardBorder, RoundedCornerShape(10.dp)).padding(16.dp)) } + } +} + + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/settings/RestoreDataScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/settings/RestoreDataScreen.kt new file mode 100644 index 0000000..e32e329 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/settings/RestoreDataScreen.kt @@ -0,0 +1,224 @@ +package com.ironlog.app.ui.screens.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.data.repository.ImportExportRepository +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.theme.IronLogType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.nio.charset.StandardCharsets +import javax.crypto.Cipher +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec + +@Composable +fun RestoreDataScreen( + onBack: () -> Unit = {}, + onOpenImportCenter: () -> Unit = {}, + onOpenBackupCenter: () -> Unit = {}, + onOpenDataPortability: (String) -> Unit = {}, + settingsRepository: SettingsRepository = SettingsRepository(), + importExportRepository: ImportExportRepository = ImportExportRepository(), +) { + val c = useTheme() + val context = LocalContext.current + val scope = rememberCoroutineScope() + var backupHealth by remember { mutableStateOf("unknown") } + var selectedSource by remember { mutableStateOf("strong_csv") } + var step by remember { mutableStateOf(1) } + var encryptedBackups by remember { mutableStateOf>(emptyList()) } + var selectedEncrypted by remember { mutableStateOf(null) } + var passphrase by remember { mutableStateOf("") } + var decryptedPayload by remember { mutableStateOf(null) } + var previewText by remember { mutableStateOf("") } + var restoreStatus by remember { mutableStateOf("") } + var replaceConfirm by remember { mutableStateOf("") } + var isDecrypting by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + backupHealth = settingsRepository.getString("last_backup_health").orEmpty().ifBlank { "unknown" } + val root = context.filesDir + encryptedBackups = withContext(Dispatchers.IO) { + root.listFiles().orEmpty() + .filter { it.isFile && it.name.startsWith("ironlog_snapshot_encrypted_") && it.extension.equals("json", true) } + .sortedByDescending { it.lastModified() } + } + } + LazyColumn( + modifier = Modifier.fillMaxSize().background(c.bg).statusBarsPadding().padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 16.dp, bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { ScreenHeader(title = "RESTORE DATA", onBack = onBack) } + item { + Card(colors = CardDefaults.cardColors(containerColor = c.card), border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder)) { + Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Encrypted Restore Wizard", color = c.text, fontSize = IronLogType.section.fontSize.sp) + Text("Step $step / 4", color = c.accent, fontSize = IronLogType.meta.fontSize.sp) + when (step) { + 1 -> { + Text("Select encrypted snapshot file", color = c.subtext) + if (encryptedBackups.isEmpty()) { + Text("No encrypted snapshots found.", color = c.muted) + } else { + encryptedBackups.forEach { file -> + AssistChip( + onClick = { selectedEncrypted = file }, + label = { Text(file.name, color = if (selectedEncrypted?.absolutePath == file.absolutePath) c.accent else c.text) }, + ) + } + } + Button(onClick = { if (selectedEncrypted != null) step = 2 }) { Text("Next: Decrypt") } + } + 2 -> { + OutlinedTextField( + value = passphrase, + onValueChange = { passphrase = it }, + label = { Text("Passphrase") }, + modifier = Modifier.fillMaxWidth(), + ) + Button( + enabled = !isDecrypting, + onClick = { + val file = selectedEncrypted + if (file != null && passphrase.length >= 8 && !isDecrypting) { + isDecrypting = true + scope.launch(Dispatchers.IO) { + runCatching { + val raw = file.readText() + val payload = decryptSnapshot(raw, passphrase) + val preview = importExportRepository.previewImportPayload(payload) + Triple(payload, "Preview: workouts=${preview.workouts}, plans=${preview.plans}, sets=${preview.sets}, body=${preview.bodyMeasurements}, warnings=${preview.warnings.size}", null as String?) + }.fold( + onSuccess = { (payload, preview, _) -> + decryptedPayload = payload + previewText = preview + restoreStatus = "" + step = 3 + }, + onFailure = { + restoreStatus = "Decrypt failed: ${it.message}" + }, + ) + isDecrypting = false + } + } + }, + ) { Text(if (isDecrypting) "Decrypting…" else "Decrypt & Preview") } + } + 3 -> { + Text(previewText, color = c.subtext) + OutlinedTextField( + value = replaceConfirm, + onValueChange = { replaceConfirm = it }, + label = { Text("Type REPLACE to confirm") }, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { if (replaceConfirm.trim().uppercase() == "REPLACE") step = 4 }, + enabled = replaceConfirm.trim().uppercase() == "REPLACE", + ) { Text("Next: Confirm Restore") } + } + else -> { + Button(onClick = { + val payload = decryptedPayload + if (payload != null) { + scope.launch(Dispatchers.IO) { + runCatching { + importExportRepository.runConfirmedImport(payload, mode = "replace") + }.onSuccess { + restoreStatus = "Encrypted restore completed." + }.onFailure { + restoreStatus = "Restore failed: ${it.message}" + } + } + } + }) { Text("Run Restore Now") } + Button(onClick = { + step = 1 + selectedEncrypted = null + passphrase = "" + decryptedPayload = null + replaceConfirm = "" + }) { Text("Reset Wizard") } + } + } + if (restoreStatus.isNotBlank()) { + Text(restoreStatus, color = c.accent, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + } + item { + Card(colors = CardDefaults.cardColors(containerColor = c.card), border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder)) { + Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Restore from local backup or import normalized files.", color = c.subtext) + Text("Backup health: $backupHealth", color = c.accent, fontSize = IronLogType.meta.fontSize.sp) + Button(onClick = onOpenBackupCenter) { Text("Restore from Backup Center") } + Button(onClick = onOpenImportCenter) { Text("Open Import Center") } + } + } + } + item { + Card(colors = CardDefaults.cardColors(containerColor = c.card), border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder)) { + Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Direct Source Import", color = c.text, fontSize = IronLogType.section.fontSize.sp) + AssistChip(onClick = { selectedSource = "strong_csv" }, label = { Text("Strong CSV", color = if (selectedSource == "strong_csv") c.accent else c.text) }) + AssistChip(onClick = { selectedSource = "hevy_csv" }, label = { Text("Hevy CSV", color = if (selectedSource == "hevy_csv") c.accent else c.text) }) + AssistChip(onClick = { selectedSource = "openweight_json" }, label = { Text("OpenWeight JSON", color = if (selectedSource == "openweight_json") c.accent else c.text) }) + AssistChip(onClick = { selectedSource = "ironlog_json" }, label = { Text("Ironlog Backup JSON", color = if (selectedSource == "ironlog_json") c.accent else c.text) }) + Button(onClick = { onOpenDataPortability(selectedSource) }) { Text("Open Validated Import Preview") } + Text("Path: Restore Data -> Data Portability ($selectedSource)", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + } +} + +private fun decryptSnapshot(raw: String, passphrase: String): String { + val obj = JSONObject(raw) + require(obj.optString("schema") == "IRONLOG_ENCRYPTED_EXPORT_V1") { "Unsupported encrypted schema" } + val salt = android.util.Base64.decode(obj.getString("saltB64"), android.util.Base64.DEFAULT) + val iv = android.util.Base64.decode(obj.getString("ivB64"), android.util.Base64.DEFAULT) + val cipherBytes = android.util.Base64.decode(obj.getString("ciphertextB64"), android.util.Base64.DEFAULT) + val iterations = obj.optInt("iterations", 120000) + val keyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val spec = PBEKeySpec(passphrase.toCharArray(), salt, iterations, 256) + val secret = SecretKeySpec(keyFactory.generateSecret(spec).encoded, "AES") + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, secret, GCMParameterSpec(128, iv)) + return String(cipher.doFinal(cipherBytes), StandardCharsets.UTF_8) +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..e11933d --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,1556 @@ +package com.ironlog.app.ui.screens.settings + +import android.content.Intent +import androidx.core.content.FileProvider +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.app.NotificationManagerCompat +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ironlog.app.ui.context.ThemeRuntime +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.model.IronLogSettings +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.viewmodel.AppDataViewModel +import com.ironlog.app.services.ReminderScheduler +import com.ironlog.app.services.WorkoutNotificationBridge +import com.ironlog.app.util.HapticsEngine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import com.ironlog.app.data.repository.WorkoutRepository +import com.ironlog.app.data.repository.ImportExportRepository +import com.ironlog.app.domain.intelligence.GeminiNanoEngine +import com.ironlog.app.domain.intelligence.NanoAvailability +import android.net.Uri +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import com.ironlog.app.domain.intelligence.CloudAiEngine +import com.ironlog.app.domain.intelligence.CloudAiKeyStore + +private val EFFORT_CYCLE = listOf("off", "rpe", "rir", "both") +private val EFFORT_LABEL = mapOf("off" to "Off", "rpe" to "RPE", "rir" to "RIR", "both" to "Both") +private val GOAL_MODES = listOf("hypertrophy" to "Hypertrophy", "strength" to "Strength", "general_fitness" to "General Fitness") +private val PROGRESSION_STYLES = listOf("conservative" to "Conservative", "balanced" to "Balanced", "aggressive" to "Aggressive") +private val PERFORMANCE_MODES = listOf( + "auto" to "Auto", + "battery" to "Battery", + "balanced" to "Balanced", + "max" to "Max", +) + +// ── Cloud AI provider presets ───────────────────────────────────────────────── + +private data class ProviderPreset( + val key: String, + val displayName: String, + val baseUrl: String, + val defaultModel: String, + val keyUrl: String, + val apiFormat: String, + val knownModels: List = emptyList(), +) + +private val PROVIDER_PRESETS = listOf( + ProviderPreset( + key = "openai", displayName = "OpenAI", baseUrl = "https://api.openai.com/v1", + defaultModel = "gpt-4o-mini", keyUrl = "https://platform.openai.com/api-keys", apiFormat = "openai", + knownModels = listOf("gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo", "o1", "o1-mini", "o3-mini"), + ), + ProviderPreset( + key = "claude", displayName = "Claude", baseUrl = "https://api.anthropic.com", + defaultModel = "claude-3-5-haiku-20241022", keyUrl = "https://platform.claude.com/settings/keys", apiFormat = "anthropic", + knownModels = listOf("claude-opus-4-5", "claude-sonnet-4-5", "claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022", "claude-3-opus-20240229"), + ), + ProviderPreset( + key = "gemini", displayName = "Gemini", baseUrl = "https://generativelanguage.googleapis.com/v1beta/openai", + defaultModel = "gemini-2.0-flash-lite", keyUrl = "https://aistudio.google.com/app/apikey", apiFormat = "openai", + knownModels = listOf("gemini-2.0-flash-lite", "gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-flash-lite", "gemini-1.5-flash", "gemini-1.5-flash-8b", "gemini-1.5-pro"), + ), + ProviderPreset( + key = "deepseek", displayName = "DeepSeek", baseUrl = "https://api.deepseek.com/v1", + defaultModel = "deepseek-chat", keyUrl = "https://platform.deepseek.com/api_keys", apiFormat = "openai", + knownModels = listOf("deepseek-chat", "deepseek-reasoner"), + ), + ProviderPreset( + key = "kimi", displayName = "Kimi", baseUrl = "https://api.moonshot.cn/v1", + defaultModel = "moonshot-v1-8k", keyUrl = "https://platform.kimi.com/console/api-keys", apiFormat = "openai", + knownModels = listOf("moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"), + ), + ProviderPreset( + key = "openrouter", displayName = "OpenRouter", baseUrl = "https://openrouter.ai/api/v1", + defaultModel = "openai/gpt-4o-mini", keyUrl = "https://openrouter.ai/settings/keys", apiFormat = "openai", + knownModels = listOf( + "openai/gpt-4o", "openai/gpt-4o-mini", "openai/o3-mini", + "anthropic/claude-3-5-sonnet", "anthropic/claude-3-5-haiku", + "google/gemini-2.0-flash", "google/gemini-2.5-pro-preview", + "deepseek/deepseek-chat", "deepseek/deepseek-r1", + "meta-llama/llama-3.1-8b-instruct:free", "mistralai/mistral-7b-instruct:free", + ), + ), + ProviderPreset( + key = "custom", displayName = "Custom", baseUrl = "", defaultModel = "", keyUrl = "", apiFormat = "openai", + ), +) + +// FIXED: 30 — Theme preview tokens: static bg + accent per theme for visual preview cards +private data class ThemeToken(val id: String, val name: String, val bg: Color, val accent: Color) +private val THEME_TOKENS = listOf( + ThemeToken("obsidian_silver", "Obsidian Silver", Color(0xFF131313), Color(0xFFFDFDFC)), + ThemeToken("deep_forest", "Deep Forest", Color(0xFF121412), Color(0xFFB0CFAD)), + ThemeToken("titanium_blue", "Titanium Blue", Color(0xFF11131C), Color(0xFFB8C3FF)), + ThemeToken("monet", "Monet (Dynamic)", Color(0xFF1A1A1A), Color(0xFFB8C3FF)), + ThemeToken("royal_amethyst", "Royal Amethyst", Color(0xFF0B1326), Color(0xFFD2BBFF)), + ThemeToken("midnight_teal", "Midnight Teal", Color(0xFF0B1326), Color(0xFF6BD8CB)), + ThemeToken("crimson_steel", "Crimson Steel", Color(0xFF141313), Color(0xFFFFB4AB)), + ThemeToken("burnt_terracotta", "Burnt Terracotta", Color(0xFF161311), Color(0xFFFFB77D)), + ThemeToken("electric_lemon", "Electric Lemon", Color(0xFF17130A), Color(0xFFFFD165)), + ThemeToken("dark", "Dark", Color(0xFF121212), Color(0xFFFF4500)), + ThemeToken("amoled", "AMOLED", Color(0xFF000000), Color(0xFFFF4500)), + ThemeToken("light", "Light", Color(0xFFF2F2F7), Color(0xFFD42010)), +) + +@Composable +fun SettingsScreen( + vm: AppDataViewModel = viewModel(), + onOpenAIPlan: () -> Unit = {}, + onOpenDataPortability: () -> Unit = {}, + onOpenProgramPicker: () -> Unit = {}, + onOpenTrainingIntelligence: () -> Unit = {}, + onOpenExerciseLibrary: () -> Unit = {}, + onOpenGymProfiles: () -> Unit = {}, + onOpenBodyWeight: () -> Unit = {}, + onOpenImportCenter: () -> Unit = {}, + onOpenPrivacy: () -> Unit = {}, +) { + val c = useTheme() + val context = LocalContext.current + val state by vm.state.collectAsState() + val settings = state.settings + val settingsRepo = remember { com.ironlog.app.data.repository.SettingsRepository() } + val workoutRepo = remember { WorkoutRepository() } + val importExportRepo = remember { ImportExportRepository() } + val scope = rememberCoroutineScope() + val versionName = remember { + runCatching { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrDefault("1.0") + } + + var confirmResetOnboarding by remember { mutableStateOf(false) } + var notifProfileState by remember { mutableStateOf("balanced") } + + // FIXED: 34 — Clear All History confirmation state (moved from HistoryScreen) + var confirmClearAll by remember { mutableStateOf(false) } + var confirmResetPbs by remember { mutableStateOf(false) } + var showNormalRestDialog by remember { mutableStateOf(false) } + var showHeavyRestDialog by remember { mutableStateOf(false) } + var showBarWeightDialog by remember { mutableStateOf(false) } + var csvImportText by remember { mutableStateOf("") } + var csvImportPreview by remember { mutableStateOf(null) } + var showCsvImportConfirm by remember { mutableStateOf(false) } + var csvImportStatus by remember { mutableStateOf("") } + var normalRestInput by remember { mutableStateOf("") } + var heavyRestInput by remember { mutableStateOf("") } + var barWeightInput by remember { mutableStateOf("") } + val notificationsAllowed = NotificationManagerCompat.from(context).areNotificationsEnabled() + + var notificationsEnabled by remember { mutableStateOf(true) } + var workoutReminders by remember { mutableStateOf(true) } + var milestoneAlertsEnabled by remember { mutableStateOf(true) } + var quietStart by remember { mutableStateOf("22:00") } + var quietEnd by remember { mutableStateOf("08:00") } + var dailyReminderInput by remember { mutableStateOf("") } + var reminderStatus by remember { mutableStateOf("") } + var showReminderTimePicker by remember { mutableStateOf(false) } + var keepAwakeDuringWorkout by remember { mutableStateOf(true) } + // Intelligence Engine section + var nanoAvailability by remember { mutableStateOf(null) } + var nanoDownloading by remember { mutableStateOf(false) } + var nanoDownloadResult by remember { mutableStateOf("") } + // Cloud AI config form state + var cloudFormExpanded by remember { mutableStateOf(false) } + var cloudPreset by remember { mutableStateOf(settings.cloudAiProviderPreset.ifBlank { "openai" }) } + var cloudBaseUrl by remember { mutableStateOf(settings.cloudAiBaseUrl) } + var cloudApiKey by remember { mutableStateOf("") } + var cloudApiKeyVisible by remember { mutableStateOf(false) } + var cloudModelName by remember { mutableStateOf(settings.cloudAiModelName) } + var cloudDisplayName by remember { mutableStateOf(settings.cloudAiDisplayName) } + var cloudModels by remember { mutableStateOf>(emptyList()) } + var cloudModelsLoading by remember { mutableStateOf(false) } + var cloudModelsError by remember { mutableStateOf("") } + var cloudShowModelSheet by remember { mutableStateOf(false) } + var cloudModelSearch by remember { mutableStateOf("") } + var cloudVerifyResult by remember { mutableStateOf("") } + var cloudVerifying by remember { mutableStateOf(false) } + val csvPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch(Dispatchers.IO) { + val text = runCatching { + context.contentResolver.openInputStream(uri)?.bufferedReader()?.readText().orEmpty() + }.getOrElse { e -> + withContext(Dispatchers.Main) { csvImportStatus = "Could not read CSV: ${e.message}" } + return@launch + } + val preview = buildImportPreview(text, "strong_csv") + withContext(Dispatchers.Main) { + csvImportText = text + csvImportPreview = preview + csvImportStatus = if (preview.error != null) "CSV preview failed: ${preview.error}" else "" + if (preview.error == null && preview.validRows > 0) showCsvImportConfirm = true + } + } + } + + LaunchedEffect(Unit) { + notificationsEnabled = settingsRepo.getBoolean("notifications_enabled", true) + milestoneAlertsEnabled = settingsRepo.getBoolean("milestone_alerts_enabled", true) + workoutReminders = settingsRepo.getBoolean("training_reminders_enabled", true) + val start = settingsRepo.getSettingNumber("quietHoursStartMinutes")?.toInt() ?: (22 * 60) + val end = settingsRepo.getSettingNumber("quietHoursEndMinutes")?.toInt() ?: (8 * 60) + quietStart = formatQuietTime(start) + quietEnd = formatQuietTime(end) + val reminderMin = settingsRepo.getSettingNumber("dailyReminderTimeMinutes")?.toInt() + dailyReminderInput = if (reminderMin != null) formatQuietTime(reminderMin) else "" + keepAwakeDuringWorkout = settingsRepo.getBoolean("keep_screen_awake_active_workout", true) + normalRestInput = settings.defaultRestSeconds.toString() + heavyRestInput = settings.defaultRestHeavySeconds.toString() + barWeightInput = settings.barWeightKg.toString() + notifProfileState = settingsRepo.getString("notification_profile") ?: "balanced" + nanoAvailability = GeminiNanoEngine.checkAvailability(context) + cloudApiKey = withContext(Dispatchers.IO) { CloudAiKeyStore.load(context, cloudPreset) } + } + + LazyColumn( + Modifier.fillMaxSize().background(c.bg).statusBarsPadding().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(top = 20.dp, bottom = 120.dp), + ) { + // ── Header ────────────────────────────────────────────────────────── + item { + Column(Modifier.padding(bottom = 4.dp)) { + // FIXED: 1 + Text("Settings", color = c.text, fontWeight = FontWeight(IronLogType.metric.fontWeight), fontSize = IronLogType.metric.fontSize.sp) + Text("Refine the app to match your training rhythm.", color = c.subtext, fontSize = IronLogType.body.fontSize.sp) + } + } + + // ── 1. YOUR PROFILE ──────────────────────────────────────────────── + item { + SettingsSection("YOUR PROFILE") { + // Display name — saves automatically on Done / focus lost + val focusManager = LocalFocusManager.current + var nameInput by remember(settings.userName) { mutableStateOf(settings.userName) } + val commitName = { + val trimmed = nameInput.trim() + if (trimmed != settings.userName) { + vm.updateSettingsAsync(settings.copy(userName = trimmed)) + } + focusManager.clearFocus() + } + OutlinedTextField( + value = nameInput, + onValueChange = { nameInput = it }, + label = { Text("Your name") }, + placeholder = { Text("Athlete") }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + .onFocusChanged { fs -> if (!fs.isFocused) commitName() }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { commitName() }), + ) + // Workout days stepper + SectionRow(borderBottom = true) { + Text("Workout days / week", color = c.text, fontSize = IronLogType.body.fontSize.sp, modifier = Modifier.weight(1f)) + Row(verticalAlignment = Alignment.CenterVertically) { + StepperBtn("-") { vm.updateSettingsAsync(settings.copy(weeklyGoalDays = max(1, (settings.weeklyGoalDays) - 1))) } + Text("${settings.weeklyGoalDays.coerceIn(1, 7)}", color = c.text, fontSize = IronLogType.section.fontSize.sp, fontWeight = FontWeight.Bold, + modifier = Modifier.widthIn(min = 32.dp).wrapContentWidth()) + StepperBtn("+") { vm.updateSettingsAsync(settings.copy(weeklyGoalDays = min(7, (settings.weeklyGoalDays) + 1))) } + } + } + // Training focus + Text("Training focus", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) + GoalChipRow(GOAL_MODES, settings.goalMode, hapticsEnabled = settings.hapticFeedback) { vm.updateSettingsAsync(settings.copy(goalMode = it)) } + // Progression style + Text("Progression style", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) + GoalChipRow(PROGRESSION_STYLES, settings.progressionStyle, hapticsEnabled = settings.hapticFeedback) { vm.updateSettingsAsync(settings.copy(progressionStyle = it)) } + // Athlete Profile nav row + SectionNavRow("Athlete Profile", onPress = onOpenTrainingIntelligence, borderBottom = false) + } + } + + // ── 1b. INTELLIGENCE ENGINE ──────────────────────────────────────── + item { + SettingsSection("INTELLIGENCE ENGINE") { + Text( + "Choose what powers the intelligence card on your Home screen.", + color = c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier.padding(bottom = 12.dp), + ) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + + // ── Built-in card ────────────────────────────────────── + val builtinSelected = settings.intelligenceMode == "builtin" + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(if (builtinSelected) c.accent.copy(alpha = 0.10f) else c.surface) + .border(1.5.dp, if (builtinSelected) c.accent else c.faint, RoundedCornerShape(IronLogRadius.lg.dp)) + .clickable { + vm.updateSettingsAsync(settings.copy(intelligenceMode = "builtin")) + cloudFormExpanded = false + } + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text("⚡", fontSize = 22.sp) + Text("Built-in", color = if (builtinSelected) c.accent else c.text, fontSize = IronLogType.body.fontSize.sp, fontWeight = FontWeight.Bold) + Text("Deterministic rule-based engine. Always works, no key required.", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, lineHeight = IronLogType.meta.lineHeight.sp) + } + + // ── APEX ENGINE card ─────────────────────────────────── + val apexSelected = settings.intelligenceMode == "gemini_nano" + val apexEnabled = nanoAvailability == NanoAvailability.SUPPORTED + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(when { apexSelected -> c.accent.copy(alpha = 0.10f); !apexEnabled -> c.surface.copy(alpha = 0.5f); else -> c.surface }) + .border(1.5.dp, when { apexSelected -> c.accent; !apexEnabled -> c.faint.copy(alpha = 0.5f); else -> c.faint }, RoundedCornerShape(IronLogRadius.lg.dp)) + .clickable(enabled = apexEnabled) { + vm.updateSettingsAsync(settings.copy(intelligenceMode = "gemini_nano")) + cloudFormExpanded = false + } + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text("✦", fontSize = 22.sp) + Text( + "APEX ENGINE", + color = when { apexSelected -> c.accent; !apexEnabled -> c.muted.copy(alpha = 0.5f); else -> c.text }, + fontSize = IronLogType.body.fontSize.sp, fontWeight = FontWeight.Bold, + ) + Text( + when (nanoAvailability) { + NanoAvailability.SUPPORTED -> "Gemini Nano AI, runs fully on-device." + NanoAvailability.NEEDS_DOWNLOAD -> "Gemini Nano AI — model download required." + NanoAvailability.NEEDS_SYSTEM_UPDATE -> "Awaiting Google Play system update to deploy Gemini Nano." + NanoAvailability.UNSUPPORTED -> "Not supported on this device." + null -> "Checking device support…" + }, + color = if (apexEnabled) c.muted else c.muted.copy(alpha = 0.6f), + fontSize = IronLogType.meta.fontSize.sp, lineHeight = IronLogType.meta.lineHeight.sp, + ) + if (nanoAvailability == NanoAvailability.NEEDS_DOWNLOAD) { + Spacer(Modifier.height(6.dp)) + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.accent.copy(alpha = 0.15f)) + .clickable(enabled = !nanoDownloading) { + scope.launch { + nanoDownloading = true + nanoDownloadResult = "" + GeminiNanoEngine.downloadModel(context) + .onSuccess { + nanoAvailability = NanoAvailability.SUPPORTED + nanoDownloadResult = "Download complete." + vm.updateSettingsAsync(settings.copy(intelligenceMode = "gemini_nano")) + } + .onFailure { nanoDownloadResult = "Download failed: ${it.message}" } + nanoDownloading = false + } + } + .padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Text(if (nanoDownloading) "Downloading…" else "Download Model", color = c.accent, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.SemiBold) + } + if (nanoDownloadResult.isNotBlank()) { + Text(nanoDownloadResult, color = if (nanoDownloadResult.startsWith("Download complete")) c.success else c.danger, fontSize = IronLogType.meta.fontSize.sp) + } + } + if (nanoAvailability == NanoAvailability.NEEDS_SYSTEM_UPDATE) { + Spacer(Modifier.height(6.dp)) + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.accent.copy(alpha = 0.15f)) + .clickable { + context.startActivity(Intent(Settings.ACTION_SETTINGS).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) + } + .padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Text("Check for Updates →", color = c.accent.copy(alpha = 0.8f), fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.SemiBold) + } + } + } + + // ── Cloud AI card ────────────────────────────────────── + val cloudSelected = settings.intelligenceMode == "cloud_ai" + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(if (cloudSelected) c.accent.copy(alpha = 0.10f) else c.surface) + .border(1.5.dp, if (cloudSelected) c.accent else c.faint, RoundedCornerShape(IronLogRadius.lg.dp)) + .clickable { cloudFormExpanded = !cloudFormExpanded } + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text("☁", fontSize = 22.sp) + Text("Cloud AI", color = if (cloudSelected) c.accent else c.text, fontSize = IronLogType.body.fontSize.sp, fontWeight = FontWeight.Bold) + Text( + if (cloudSelected && settings.cloudAiDisplayName.isNotBlank()) + "Active: ${settings.cloudAiDisplayName} · ${settings.cloudAiModelName}" + else + "Use your own API key for any OpenAI-compatible provider or Claude.", + color = c.muted, fontSize = IronLogType.meta.fontSize.sp, lineHeight = IronLogType.meta.lineHeight.sp, + ) + } + } + + // ── Cloud AI config form ─────────────────────────────────── + AnimatedVisibility(visible = cloudFormExpanded) { + Column(Modifier.fillMaxWidth().padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + + // Provider preset chips + Text("Provider", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.SemiBold) + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + PROVIDER_PRESETS.forEach { preset -> + val sel = cloudPreset == preset.key + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(if (sel) c.accent.copy(alpha = 0.18f) else c.surface) + .border(1.dp, if (sel) c.accent else c.faint, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { + cloudPreset = preset.key + if (preset.key != "custom") { + cloudBaseUrl = preset.baseUrl + cloudModelName = preset.defaultModel + cloudDisplayName = preset.displayName + } + // Load this provider's saved key (empty string if not yet set) + cloudApiKey = CloudAiKeyStore.load(context, preset.key) + cloudModels = emptyList() + cloudModelsError = "" + cloudVerifyResult = "" + } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text(preset.displayName, color = if (sel) c.accent else c.muted, fontSize = IronLogType.micro.fontSize.sp, fontWeight = if (sel) FontWeight.SemiBold else FontWeight.Normal) + } + } + } + + // Base URL + OutlinedTextField( + value = cloudBaseUrl, + onValueChange = { cloudBaseUrl = it }, + label = { Text("Base URL") }, + placeholder = { Text("https://api.openai.com/v1") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), + ) + + // "Get API key →" link (shown for non-custom presets) + val activePreset = PROVIDER_PRESETS.find { it.key == cloudPreset } + if (activePreset != null && activePreset.keyUrl.isNotBlank()) { + Text( + "Get API key →", + color = c.accent, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .clickable { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activePreset.keyUrl)).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) + } + .padding(vertical = 2.dp), + ) + } + + // API Key (masked) + OutlinedTextField( + value = cloudApiKey, + onValueChange = { cloudApiKey = it }, + label = { Text("API Key") }, + placeholder = { Text("sk-…") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = if (cloudApiKeyVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + Icon( + if (cloudApiKeyVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = if (cloudApiKeyVisible) "Hide key" else "Show key", + tint = c.muted, + modifier = Modifier.clickable { cloudApiKeyVisible = !cloudApiKeyVisible }, + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + ) + + // Load Models / Browse preset models row + val activePresetForModels = PROVIDER_PRESETS.find { it.key == cloudPreset } + val hasKnownModels = (activePresetForModels?.knownModels?.isNotEmpty()) == true + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + if (hasKnownModels) { + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.surface) + .border(1.dp, c.faint, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { + cloudModels = activePresetForModels!!.knownModels + cloudModelsError = "" + cloudShowModelSheet = true + } + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text( + "Browse ${activePresetForModels!!.knownModels.size} Models", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.surface) + .border(1.dp, c.faint, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable(enabled = !cloudModelsLoading && cloudApiKey.isNotBlank() && cloudBaseUrl.isNotBlank()) { + scope.launch { + cloudModelsLoading = true + cloudModelsError = "" + val fmt = PROVIDER_PRESETS.find { it.key == cloudPreset }?.apiFormat ?: "openai" + CloudAiEngine.fetchModels(cloudBaseUrl, cloudApiKey, fmt) + .onSuccess { models -> + cloudModels = models.sortedBy { it.lowercase() } + cloudShowModelSheet = true + } + .onFailure { e -> + cloudModelsError = when { + e.message?.contains("401") == true || e.message?.contains("403") == true -> "Invalid API key." + else -> e.message?.take(120) ?: "Couldn't load models." + } + } + cloudModelsLoading = false + } + } + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text( + if (cloudModelsLoading) "Loading…" else "Load Models", + color = c.text, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + if (cloudModelsError.isNotBlank()) { + Text(cloudModelsError, color = c.danger, fontSize = IronLogType.meta.fontSize.sp) + } + + // Model field — clickable if any models are loaded (from API or preset) + val hasAnyModels = cloudModels.isNotEmpty() + OutlinedTextField( + value = cloudModelName, + onValueChange = { cloudModelName = it }, + label = { Text("Model") }, + placeholder = { Text("gpt-4o-mini") }, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = hasAnyModels) { cloudShowModelSheet = true }, + singleLine = true, + readOnly = hasAnyModels, + trailingIcon = if (hasAnyModels) { + { Text("▾", color = c.muted, fontSize = 16.sp) } + } else null, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + + // Display name + OutlinedTextField( + value = cloudDisplayName, + onValueChange = { cloudDisplayName = it }, + label = { Text("Display name (optional)") }, + placeholder = { Text("OpenAI") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + ) + + // Verify + Save row + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + if (cloudVerifyResult.isNotBlank()) { + Text( + cloudVerifyResult, + color = if (cloudVerifyResult.startsWith("✓")) c.success else c.danger, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier.weight(1f), + ) + } + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.surface) + .border(1.dp, c.faint, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable(enabled = !cloudVerifying && cloudApiKey.isNotBlank() && cloudBaseUrl.isNotBlank() && cloudModelName.isNotBlank()) { + scope.launch { + cloudVerifying = true + cloudVerifyResult = "" + val fmt = PROVIDER_PRESETS.find { it.key == cloudPreset }?.apiFormat ?: "openai" + CloudAiEngine.verify(cloudBaseUrl, cloudApiKey, cloudModelName, fmt) + .onSuccess { cloudVerifyResult = "✓ Connection verified" } + .onFailure { e -> cloudVerifyResult = "✗ ${e.message?.take(80) ?: "Failed"}" } + cloudVerifying = false + } + } + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text(if (cloudVerifying) "Verifying…" else "Verify", color = c.text, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.SemiBold) + } + Box( + Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.accent.copy(alpha = 0.15f)) + .clickable(enabled = cloudApiKey.isNotBlank() && cloudBaseUrl.isNotBlank() && cloudModelName.isNotBlank()) { + scope.launch { + val fmt = PROVIDER_PRESETS.find { it.key == cloudPreset }?.apiFormat ?: "openai" + withContext(Dispatchers.IO) { CloudAiKeyStore.save(context, cloudPreset, cloudApiKey) } + vm.updateSettingsAsync( + settings.copy( + intelligenceMode = "cloud_ai", + cloudAiBaseUrl = cloudBaseUrl, + cloudAiModelName = cloudModelName, + cloudAiDisplayName = cloudDisplayName.ifBlank { activePreset?.displayName ?: "Cloud AI" }, + cloudAiProviderPreset = cloudPreset, + cloudAiApiFormat = fmt, + ) + ) + cloudFormExpanded = false + } + } + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text("Save", color = c.accent, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Bold) + } + } + } + } + } + } + + // ── 2. APPEARANCE ────────────────────────────────────────────────── + item { + SettingsSection("APPEARANCE") { + // Track the live theme via ThemeRuntime so the active border + // updates instantly on tap (settings.theme can lag the async write) + // FIXED: 30 — Visual preview cards (120×72dp) replacing pill chips + val activeThemeName by ThemeRuntime.themeName.collectAsState() + @OptIn(ExperimentalLayoutApi::class) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + THEME_TOKENS.forEach { t -> + val active = activeThemeName == t.id + // Determine if this theme's background is light (luminance > 0.5) + // so we can use dark text/borders instead of white ones + val bgLuminance = (0.299f * t.bg.red + 0.587f * t.bg.green + 0.114f * t.bg.blue) + val isLightBg = bgLuminance > 0.5f + val onBgColor = if (isLightBg) Color(0xFF1A1A1A) else Color.White + val cardOverlayColor = if (isLightBg) Color.Black.copy(alpha = 0.08f) else Color.White.copy(alpha = 0.08f) + val borderInactiveColor = if (isLightBg) Color.Black.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.08f) + Column( + modifier = Modifier + .width(120.dp) + .clip(RoundedCornerShape(IronLogRadius.md.dp)) + .background(t.bg) + .border( + width = if (active) 2.dp else 1.dp, + color = if (active) t.accent else borderInactiveColor, + shape = RoundedCornerShape(IronLogRadius.md.dp), + ) + .clickable { vm.updateSettingsAsync(settings.copy(theme = t.id)); ThemeRuntime.setTheme(t.id) } + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + // Mini screen preview + Box( + Modifier + .fillMaxWidth() + .height(44.dp) + .clip(RoundedCornerShape(6.dp)) + .background(t.bg), + ) { + // Inner card simulation + Box( + Modifier + .fillMaxWidth(0.7f) + .fillMaxHeight(0.6f) + .padding(4.dp) + .clip(RoundedCornerShape(4.dp)) + .background(cardOverlayColor) + .align(Alignment.TopStart), + ) + // Accent dot + Box( + Modifier + .size(10.dp) + .clip(CircleShape) + .background(t.accent) + .align(Alignment.BottomEnd), + ) + } + Text( + t.name, + color = if (active) t.accent else onBgColor.copy(alpha = 0.75f), + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = if (active) FontWeight.ExtraBold else FontWeight.Medium, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + } + } + } + Spacer(Modifier.height(4.dp)) + } + } + + // ── 3. WORKOUT ───────────────────────────────────────────────────── + item { + SettingsSection("WORKOUT") { + // Weight unit + Text("Weight unit", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, modifier = Modifier.padding(bottom = 8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) { + listOf("kg", "lbs").forEach { unit -> + val active = settings.weightUnit == unit + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(10.dp)) + .background(if (active) c.accent.copy(alpha = 0.18f) else c.surface) + .border(1.5.dp, if (active) c.accent else c.faint, RoundedCornerShape(10.dp)) + .clickable { + if (settings.hapticFeedback) HapticsEngine.lightConfirm(context) + vm.updateSettingsAsync(settings.copy(weightUnit = unit)) + } + .padding(12.dp), + contentAlignment = Alignment.Center + ) { + Text( + unit.uppercase(), + color = if (active) c.accent else c.subtext, + fontWeight = if (active) FontWeight.Black else FontWeight.Bold, + fontSize = IronLogType.section.fontSize.sp, + letterSpacing = 2.sp, + ) + } + } + } + // Effort tracking + SectionRow(borderBottom = true) { + Text("Effort tracking", color = c.text, fontSize = IronLogType.body.fontSize.sp, modifier = Modifier.weight(1f)) + Text(EFFORT_LABEL[settings.effortTracking] ?: "Off", color = c.subtext, fontSize = IronLogType.body.fontSize.sp, modifier = Modifier.clickable { cycleEffort(settings, vm) }) + } + Text("Performance mode", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) + GoalChipRow(PERFORMANCE_MODES, settings.performanceMode, hapticsEnabled = settings.hapticFeedback) { + vm.updateSettingsAsync(settings.copy(performanceMode = it)) + } + // Rest timers + SectionRow(borderBottom = true) { + Text( + "Rest — normal sets", + color = c.text, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier.weight(1f).clickable { showNormalRestDialog = true }, + ) + Text( + "${settings.defaultRestSeconds}s", + color = c.subtext, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier.clickable { showNormalRestDialog = true }, + ) + } + SectionRow(borderBottom = true) { + Text( + "Rest — heavy sets", + color = c.text, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier.weight(1f).clickable { showHeavyRestDialog = true }, + ) + Text( + "${settings.defaultRestHeavySeconds}s", + color = c.subtext, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier.clickable { showHeavyRestDialog = true }, + ) + } + SectionRow(borderBottom = true) { + Text( + "Bar weight", + color = c.text, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier.weight(1f).clickable { showBarWeightDialog = true }, + ) + Text( + "${settings.barWeightKg} kg", + color = c.subtext, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier.clickable { showBarWeightDialog = true }, + ) + } + // Haptics + SectionRow(borderBottom = true) { + Column(Modifier.weight(1f)) { + Text("Haptic feedback", color = c.text, fontSize = IronLogType.body.fontSize.sp) + } + Switch( + checked = settings.hapticFeedback, + onCheckedChange = { + HapticsEngine.toggle(context, it) + vm.updateSettingsAsync(settings.copy(hapticFeedback = it)) + }, + colors = SwitchDefaults.colors(checkedThumbColor = c.accent, checkedTrackColor = c.accentSoft) + ) + } + // Keep awake + SectionRow(borderBottom = false) { + Text("Keep screen awake during workout", color = c.text, fontSize = IronLogType.body.fontSize.sp, modifier = Modifier.weight(1f)) + Switch( + checked = keepAwakeDuringWorkout, + onCheckedChange = { + if (settings.hapticFeedback) HapticsEngine.toggle(context, it) + keepAwakeDuringWorkout = it + scope.launch(Dispatchers.IO) { settingsRepo.setBoolean("keep_screen_awake_active_workout", it) } + }, + colors = SwitchDefaults.colors(checkedThumbColor = c.accent, checkedTrackColor = c.accentSoft) + ) + } + } + } + + // ── 4. TOOLS ─────────────────────────────────────────────────────── + item { + SettingsSection("TOOLS") { + SectionNavRow("Exercise Library", onPress = onOpenExerciseLibrary, hapticsEnabled = settings.hapticFeedback) + SectionNavRow("Gym Profiles", onPress = onOpenGymProfiles, hapticsEnabled = settings.hapticFeedback) + SectionNavRow("Body Weight Tracker", onPress = onOpenBodyWeight, hapticsEnabled = settings.hapticFeedback) + SectionNavRow("AI Plan Creator", onPress = onOpenAIPlan, hapticsEnabled = settings.hapticFeedback) + SectionNavRow("Program Picker", onPress = onOpenProgramPicker, borderBottom = false, hapticsEnabled = settings.hapticFeedback) + } + } + + // ── 5. REMINDERS ─────────────────────────────────────────────────── + item { + SettingsSection("REMINDERS") { + if (!notificationsAllowed) { + SectionRow(borderBottom = true) { + Text( + "Notifications are disabled — tap to open settings", + color = c.warning, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier + .weight(1f) + .clickable { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + }, + ) + Icon(Icons.Filled.ChevronRight, null, tint = c.warning) + } + } + SectionRow(borderBottom = true) { + Text("Smart notifications", color = c.text, fontSize = IronLogType.body.fontSize.sp, modifier = Modifier.weight(1f)) + Switch( + checked = notificationsEnabled, + onCheckedChange = { + if (settings.hapticFeedback) HapticsEngine.toggle(context, it) + notificationsEnabled = it + scope.launch(Dispatchers.IO) { + settingsRepo.setBoolean("notifications_enabled", it) + if (it) { + val minutes = parseQuietTimeInput(dailyReminderInput.trim()) + ?: settingsRepo.getSettingNumber("dailyReminderTimeMinutes")?.toInt() + ?: (8 * 60) + ReminderScheduler.scheduleDailyReminder(context, minutes / 60, minutes % 60) + } else { + ReminderScheduler.cancelDailyReminder(context) + } + } + }, + colors = SwitchDefaults.colors(checkedThumbColor = c.accent, checkedTrackColor = c.accentSoft) + ) + } + SectionRow(borderBottom = true) { + Text("Workout reminders", color = c.text, fontSize = IronLogType.body.fontSize.sp, modifier = Modifier.weight(1f)) + Switch( + checked = workoutReminders, + onCheckedChange = { + if (settings.hapticFeedback) HapticsEngine.toggle(context, it) + workoutReminders = it + scope.launch(Dispatchers.IO) { settingsRepo.setBoolean("training_reminders_enabled", it) } + }, + colors = SwitchDefaults.colors(checkedThumbColor = c.accent, checkedTrackColor = c.accentSoft) + ) + } + SectionRow(borderBottom = true) { + Text("Milestone alerts", color = c.text, fontSize = IronLogType.body.fontSize.sp, modifier = Modifier.weight(1f)) + Switch( + checked = milestoneAlertsEnabled, + onCheckedChange = { + if (settings.hapticFeedback) HapticsEngine.toggle(context, it) + milestoneAlertsEnabled = it + scope.launch(Dispatchers.IO) { settingsRepo.setBoolean("milestone_alerts_enabled", it) } + }, + colors = SwitchDefaults.colors(checkedThumbColor = c.accent, checkedTrackColor = c.accentSoft) + ) + } + // Daily reminder time + // FIXED: 32 — Replaced uppercase micro label with readable copy + Text("Daily reminder time", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Medium, modifier = Modifier.padding(top = 12.dp, bottom = 2.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Bottom) { + OutlinedTextField( + value = dailyReminderInput, onValueChange = { dailyReminderInput = it }, + placeholder = { Text("18:30", color = c.muted) }, + label = { Text("Time (HH:MM)") }, + modifier = Modifier.weight(1f), + singleLine = true, + trailingIcon = { + IconButton(onClick = { showReminderTimePicker = true }) { + Icon(Icons.Outlined.Schedule, contentDescription = "Pick time", tint = c.muted) + } + }, + ) + Box( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(c.accentSoft) + .border(1.dp, c.accentBorder, RoundedCornerShape(10.dp)) + .clickable { + scope.launch(Dispatchers.IO) { + val minutes = parseQuietTimeInput(dailyReminderInput.trim()) + if (minutes != null) { + settingsRepo.setSetting("dailyReminderTimeMinutes", minutes, "number") + if (notificationsEnabled) { + ReminderScheduler.scheduleDailyReminder(context, minutes / 60, minutes % 60) + } + } else { + settingsRepo.setSetting("dailyReminderTimeMinutes", null, "number") + } + } + } + .padding(horizontal = 16.dp, vertical = 14.dp) + ) { Text("Apply", color = c.accent, fontWeight = FontWeight.Bold, fontSize = IronLogType.body.fontSize.sp) } + } + // FIXED: 33 — Context link between reminder and quiet hours + Text( + "Reminders are suppressed during quiet hours.", + color = c.muted, + fontSize = IronLogType.micro.fontSize.sp, + modifier = Modifier.padding(top = 4.dp, bottom = 2.dp), + ) + // Quiet hours + Text("Quiet hours", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Medium, modifier = Modifier.padding(top = 12.dp, bottom = 6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + Column(Modifier.weight(1f)) { + Text("Start", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, modifier = Modifier.padding(bottom = 6.dp)) + OutlinedTextField(value = quietStart, onValueChange = { quietStart = it }, placeholder = { Text("22:00", color = c.muted) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + Column(Modifier.weight(1f)) { + Text("End", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, modifier = Modifier.padding(bottom = 6.dp)) + OutlinedTextField(value = quietEnd, onValueChange = { quietEnd = it }, placeholder = { Text("08:00", color = c.muted) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp) + .clip(RoundedCornerShape(10.dp)) + .background(c.accentSoft) + .border(1.dp, c.accentBorder, RoundedCornerShape(10.dp)) + .clickable { + val s = parseQuietTimeInput(quietStart) + val e = parseQuietTimeInput(quietEnd) + if (s != null && e != null) { + scope.launch(Dispatchers.IO) { + settingsRepo.setSetting("quietHoursStartMinutes", s, "number") + settingsRepo.setSetting("quietHoursEndMinutes", e, "number") + val reminderParsed = parseQuietTimeInput(dailyReminderInput.trim()) ?: (8 * 60) + if (notificationsEnabled) ReminderScheduler.scheduleDailyReminder(context, reminderParsed / 60, reminderParsed % 60) + else ReminderScheduler.cancelDailyReminder(context) + } + reminderStatus = "Saved" + } + } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { Text("Save Reminder Settings", color = c.accent, fontWeight = FontWeight.Bold, fontSize = IronLogType.body.fontSize.sp) } + if (reminderStatus.isNotBlank()) Text(reminderStatus, color = c.accent, fontSize = IronLogType.meta.fontSize.sp, modifier = Modifier.padding(top = 6.dp)) + // Notification frequency profile + Text("Notification frequency", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Medium, modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) { + listOf("conservative" to "Conservative", "balanced" to "Balanced", "aggressive" to "Aggressive").forEach { (id, label) -> + val active = notifProfileState == id + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(10.dp)) + .background(if (active) c.accent.copy(alpha = 0.18f) else c.surface) + .border(1.5.dp, if (active) c.accent else c.faint, RoundedCornerShape(10.dp)) + .clickable { + if (settings.hapticFeedback) HapticsEngine.lightConfirm(context) + notifProfileState = id + scope.launch(Dispatchers.IO) { + settingsRepo.setSetting("notification_profile", id, "string") + } + } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, color = if (active) c.accent else c.muted, fontSize = IronLogType.meta.fontSize.sp, fontWeight = if (active) FontWeight.ExtraBold else FontWeight.Normal) + } + } + } + // Test notification + SectionRow(borderBottom = false, modifier = Modifier.padding(top = 4.dp)) { + Text("Send test notification", color = c.text, fontSize = IronLogType.body.fontSize.sp, modifier = Modifier.weight(1f).clickable { + if (!notificationsAllowed) { + context.startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }) + } else { + WorkoutNotificationBridge.showTestNotification(context) + } + }) + Icon(Icons.Filled.ChevronRight, null, tint = c.muted) + } + } + } + + // ── 6. DATA & BACKUP ─────────────────────────────────────────────── + item { + // FIXED: 34 — Clear All History moved here from HistoryScreen + SettingsSection("DATA & BACKUP") { + SectionNavRow("Import from Other Apps", onPress = onOpenImportCenter, hapticsEnabled = settings.hapticFeedback) + SectionNavRow("Backup & Export", onPress = onOpenDataPortability, hapticsEnabled = settings.hapticFeedback) + SectionNavRow("Export history as CSV", onPress = { + val csv = buildHistoryCsv(state.history) + val file = File(context.cacheDir, "ironlog_history_export.csv") + file.writeText(csv) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + context.startActivity( + Intent(Intent.ACTION_SEND).apply { + type = "text/csv" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "IronLog history export") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }, + ) + }, hapticsEnabled = settings.hapticFeedback) + SectionNavRow("Import history from CSV", onPress = { + csvPicker.launch(arrayOf("text/csv", "text/plain", "*/*")) + }, hapticsEnabled = settings.hapticFeedback) + SectionNavRow("Clear All History", onPress = { confirmClearAll = true }, hapticsEnabled = settings.hapticFeedback) + SectionNavRow("Reset All PRs", onPress = { confirmResetPbs = true }, borderBottom = false, hapticsEnabled = settings.hapticFeedback) + } + } + + // ── 7. ABOUT ─────────────────────────────────────────────────────── + item { + SettingsSection("ABOUT") { + SectionNavRow("Privacy & Data", onPress = onOpenPrivacy, hapticsEnabled = settings.hapticFeedback) + SectionRow(borderBottom = true) { + Text("Version", color = c.muted, fontSize = IronLogType.body.fontSize.sp, modifier = Modifier.weight(1f)) + Text("IronLog $versionName", color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + } + SectionRow(borderBottom = false) { + Text("Restart app tutorial", color = c.text, fontSize = IronLogType.body.fontSize.sp, modifier = Modifier.weight(1f).clickable { confirmResetOnboarding = true }) + Icon(Icons.Filled.ChevronRight, null, tint = c.muted) + } + } + } + + item { Spacer(Modifier.height(40.dp)) } + } + + // FIXED: 34 — Clear All History confirmation dialog + if (confirmClearAll) { + AlertDialog( + onDismissRequest = { confirmClearAll = false }, + title = { Text("Clear All History?") }, + text = { Text("This will permanently delete all workout history. This action cannot be undone.") }, + confirmButton = { + TextButton(onClick = { vm.clearAllHistory(); confirmClearAll = false }) { + Text("Clear All", color = c.danger) + } + }, + dismissButton = { + TextButton(onClick = { confirmClearAll = false }) { Text("Cancel") } + }, + ) + } + + if (confirmResetPbs) { + AlertDialog( + onDismissRequest = { confirmResetPbs = false }, + title = { Text("Reset all PRs?") }, + text = { Text("This will clear all personal best records and PR summaries.") }, + confirmButton = { + TextButton(onClick = { vm.clearPbs(); confirmResetPbs = false }) { + Text("Reset PRs", color = c.danger) + } + }, + dismissButton = { + TextButton(onClick = { confirmResetPbs = false }) { Text("Cancel") } + }, + ) + } + + if (showNormalRestDialog) { + AlertDialog( + onDismissRequest = { showNormalRestDialog = false }, + title = { Text("Normal set rest (seconds)") }, + text = { + OutlinedTextField( + value = normalRestInput, + onValueChange = { v -> if (v.all(Char::isDigit)) normalRestInput = v }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + }, + confirmButton = { + TextButton(onClick = { + val sec = normalRestInput.toIntOrNull()?.coerceIn(15, 600) ?: settings.defaultRestSeconds + vm.updateSettingsAsync(settings.copy(defaultRestSeconds = sec)) + showNormalRestDialog = false + }) { Text("Save", color = c.accent) } + }, + dismissButton = { TextButton(onClick = { showNormalRestDialog = false }) { Text("Cancel") } }, + ) + } + + if (showHeavyRestDialog) { + AlertDialog( + onDismissRequest = { showHeavyRestDialog = false }, + title = { Text("Heavy set rest (seconds)") }, + text = { + OutlinedTextField( + value = heavyRestInput, + onValueChange = { v -> if (v.all(Char::isDigit)) heavyRestInput = v }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + }, + confirmButton = { + TextButton(onClick = { + val sec = heavyRestInput.toIntOrNull()?.coerceIn(30, 900) ?: settings.defaultRestHeavySeconds + vm.updateSettingsAsync(settings.copy(defaultRestHeavySeconds = sec)) + showHeavyRestDialog = false + }) { Text("Save", color = c.accent) } + }, + dismissButton = { TextButton(onClick = { showHeavyRestDialog = false }) { Text("Cancel") } }, + ) + } + if (showBarWeightDialog) { + AlertDialog( + onDismissRequest = { showBarWeightDialog = false }, + title = { Text("Bar weight (kg)") }, + text = { + OutlinedTextField( + value = barWeightInput, + onValueChange = { barWeightInput = it }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + ) + }, + confirmButton = { + TextButton(onClick = { + val next = barWeightInput.toDoubleOrNull()?.coerceIn(0.0, 100.0) ?: settings.barWeightKg + vm.updateSettingsAsync(settings.copy(barWeightKg = next)) + showBarWeightDialog = false + }) { Text("Save", color = c.accent) } + }, + dismissButton = { TextButton(onClick = { showBarWeightDialog = false }) { Text("Cancel") } }, + ) + } + if (showCsvImportConfirm) { + val pv = csvImportPreview + AlertDialog( + onDismissRequest = { showCsvImportConfirm = false }, + title = { Text("Import CSV history?") }, + text = { + Text( + if (pv == null) "No preview available." + else "Import ${pv.validRows} rows (${pv.domainCounts["workouts"] ?: 0} workouts, ${pv.domainCounts["sets"] ?: 0} sets)?", + ) + }, + confirmButton = { + TextButton(onClick = { + showCsvImportConfirm = false + val preview = csvImportPreview ?: return@TextButton + scope.launch(Dispatchers.IO) { + val imported = runCatching { + importText( + text = csvImportText, + repo = workoutRepo, + importExportRepo = importExportRepo, + preview = preview, + sourceHint = "strong_csv", + replaceMode = false, + ) + } + withContext(Dispatchers.Main) { + csvImportStatus = imported.fold( + onSuccess = { "Imported $it workout session(s) from CSV." }, + onFailure = { "CSV import failed: ${it.message}" }, + ) + csvImportText = "" + csvImportPreview = null + } + } + }) { Text("Import", color = c.accent) } + }, + dismissButton = { + TextButton(onClick = { showCsvImportConfirm = false }) { Text("Cancel") } + }, + ) + } + if (csvImportStatus.isNotBlank()) { + AlertDialog( + onDismissRequest = { csvImportStatus = "" }, + title = { Text("CSV Import") }, + text = { Text(csvImportStatus) }, + confirmButton = { TextButton(onClick = { csvImportStatus = "" }) { Text("OK") } }, + ) + } + + if (confirmResetOnboarding) { + AlertDialog( + onDismissRequest = { confirmResetOnboarding = false }, + title = { Text("Restart tutorial?") }, + text = { Text("This will restart the onboarding flow on next app open.") }, + confirmButton = { + TextButton(onClick = { confirmResetOnboarding = false; vm.resetOnboarding() }) { + Text("Restart", color = c.accent) + } + }, + dismissButton = { TextButton(onClick = { confirmResetOnboarding = false }) { Text("Cancel") } }, + ) + } + + // Daily reminder time picker dialog + if (showReminderTimePicker) { + val parsed = parseQuietTimeInput(dailyReminderInput.trim()) + val initHour = parsed?.div(60) ?: 18 + val initMin = parsed?.rem(60) ?: 0 + @OptIn(ExperimentalMaterial3Api::class) + val tpState = rememberTimePickerState(initialHour = initHour, initialMinute = initMin, is24Hour = true) + AlertDialog( + onDismissRequest = { showReminderTimePicker = false }, + containerColor = c.card, + title = { Text("Set Reminder Time", color = c.text) }, + text = { + @OptIn(ExperimentalMaterial3Api::class) + TimePicker(state = tpState) + }, + confirmButton = { + TextButton(onClick = { + @OptIn(ExperimentalMaterial3Api::class) + dailyReminderInput = "%02d:%02d".format(tpState.hour, tpState.minute) + scope.launch(Dispatchers.IO) { + @OptIn(ExperimentalMaterial3Api::class) + settingsRepo.setSetting("dailyReminderTimeMinutes", tpState.hour * 60 + tpState.minute, "number") + } + showReminderTimePicker = false + }) { Text("SET", color = c.accent) } + }, + dismissButton = { + TextButton(onClick = { showReminderTimePicker = false }) { Text("CANCEL", color = c.muted) } + }, + ) + } + + // ── Cloud AI model picker bottom sheet ───────────────────────────────── + if (cloudShowModelSheet) { + @OptIn(ExperimentalMaterial3Api::class) + ModalBottomSheet( + onDismissRequest = { cloudShowModelSheet = false; cloudModelSearch = "" }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + // Fixed-height column so the sheet is always fully expanded — prevents the + // list's overscroll from bubbling into the sheet's drag-to-dismiss gesture. + Column( + Modifier + .fillMaxWidth() + .fillMaxHeight(0.6f) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Select Model", color = c.text, fontSize = IronLogType.body.fontSize.sp, fontWeight = FontWeight.Bold) + OutlinedTextField( + value = cloudModelSearch, + onValueChange = { cloudModelSearch = it }, + label = { Text("Search") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + val filtered = cloudModels.filter { it.contains(cloudModelSearch, ignoreCase = true) } + LazyColumn(Modifier.fillMaxWidth().weight(1f)) { + items(filtered.size) { idx -> + val model = filtered[idx] + Text( + model, + color = if (model == cloudModelName) c.accent else c.text, + fontSize = IronLogType.body.fontSize.sp, + fontWeight = if (model == cloudModelName) FontWeight.SemiBold else FontWeight.Normal, + modifier = Modifier + .fillMaxWidth() + .clickable { + cloudModelName = model + cloudShowModelSheet = false + cloudModelSearch = "" + } + .padding(vertical = 10.dp, horizontal = 4.dp), + ) + } + } + } + } + } + +} + +private fun buildHistoryCsv(history: List): String { + val header = "date,workout_name,exercise_name,set,weight_kg,reps,type" + val rows = history.flatMap { h -> + h.exercises.flatMap { ex -> + ex.sets.mapIndexed { idx, st -> + listOf( + h.date.substringBefore('T'), + h.name, + ex.name, + (idx + 1).toString(), + st.weight.toString(), + st.reps.toString(), + st.type, + ).joinToString(",") { csvEscape(it) } + } + } + } + return (listOf(header) + rows).joinToString("\n") +} + +private fun csvEscape(v: String): String { + val mustQuote = v.contains(",") || v.contains("\"") || v.contains("\n") + val cleaned = v.replace("\"", "\"\"") + return if (mustQuote) "\"$cleaned\"" else cleaned +} + +// ── Shared composables ──────────────────────────────────────────────────────── + +@Composable +private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) { + val c = useTheme() + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = BorderStroke(1.dp, c.cardBorder), + shape = RoundedCornerShape(IronLogRadius.xl.dp), + ) { + Column(Modifier.fillMaxWidth().padding(16.dp)) { + Text(title, color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 3.sp, modifier = Modifier.padding(bottom = 12.dp)) + content() + } + } +} + +@Composable +private fun SectionRow(borderBottom: Boolean = true, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) { + val c = useTheme() + Row( + modifier = modifier + .fillMaxWidth() + .then(if (borderBottom) Modifier.border(BorderStroke(0.dp, Color.Transparent)) else Modifier) + .padding(vertical = 13.dp) + .then(if (borderBottom) Modifier.drawBottomBorder(c.faint) else Modifier), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + content = content + ) +} + +@Composable +private fun SectionNavRow(label: String, onPress: () -> Unit, borderBottom: Boolean = true, hapticsEnabled: Boolean = true) { + val c = useTheme() + val context = LocalContext.current + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + if (hapticsEnabled) HapticsEngine.lightConfirm(context) + onPress() + } + .padding(vertical = 13.dp) + .then(if (borderBottom) Modifier.drawBottomBorder(c.faint) else Modifier), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, color = c.text, fontSize = IronLogType.body.fontSize.sp, modifier = Modifier.weight(1f)) + Icon(Icons.Filled.ChevronRight, null, tint = c.muted) + } +} + +// FIXED: 31 — StepperBtn enlarged to 44dp for touch-target compliance +@Composable +private fun StepperBtn(label: String, onClick: () -> Unit) { + val c = useTheme() + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, c.faint, RoundedCornerShape(8.dp)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Text(label, color = c.text, fontSize = IronLogType.section.fontSize.sp, fontWeight = FontWeight.Bold) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun GoalChipRow(options: List>, selected: String, hapticsEnabled: Boolean = true, onSelect: (String) -> Unit) { + val c = useTheme() + val context = LocalContext.current + FlowChipRow { + options.forEach { (id, label) -> + val active = selected == id + Box( + modifier = Modifier + .clip(CircleShape) + // Use a direct accent fill + solid accent ring so the selected state is + // always visible regardless of theme (accentSoft is too faint on some themes). + .background(if (active) c.accent.copy(alpha = 0.18f) else Color.Transparent) + .border(1.5.dp, if (active) c.accent else c.faint, CircleShape) + .clickable { + if (hapticsEnabled) HapticsEngine.selection(context) + onSelect(id) + } + .padding(horizontal = 14.dp, vertical = 8.dp) + ) { + Text( + label, + color = if (active) c.accent else c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = if (active) FontWeight.ExtraBold else FontWeight.Bold, + letterSpacing = 0.6.sp, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun FlowChipRow(content: @Composable FlowRowScope.() -> Unit) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + content = content, + ) +} + +internal fun Modifier.drawBottomBorder(color: Color): Modifier = this.drawBehind { + val strokeWidth = 1.dp.toPx() + drawLine(color, Offset(0f, size.height), Offset(size.width, size.height), strokeWidth) + } + +private fun cycleEffort(settings: IronLogSettings, vm: AppDataViewModel) { + val idx = EFFORT_CYCLE.indexOf(settings.effortTracking).let { if (it < 0) 0 else it } + val next = EFFORT_CYCLE[(idx + 1) % EFFORT_CYCLE.size] + vm.updateSettingsAsync(settings.copy(effortTracking = next)) +} + +fun formatBytes(bytes: Long): String = if (bytes < 1024L * 1024L) "${"%.1f".format(bytes / 1024.0)} KB" else "${"%.1f".format(bytes / (1024.0 * 1024.0))} MB" +fun formatQuietTime(totalMinutes: Int): String { val minutes = max(0, min(1439, totalMinutes)); return "${(minutes / 60).toString().padStart(2, '0')}:${(minutes % 60).toString().padStart(2, '0')}" } +fun parseQuietTimeInput(value: String): Int? { + val m = Regex("^(\\d{1,2})(?::?(\\d{1,2}))?$").matchEntire(value.trim()) ?: return null + val hours = m.groupValues[1].toIntOrNull() ?: return null + val minutes = m.groupValues.getOrNull(2)?.takeIf { it.isNotBlank() }?.toIntOrNull() ?: 0 + if (hours !in 0..23 || minutes !in 0..59) return null + return hours * 60 + minutes +} +fun getQuietMinutes(settings: Map, key: String = "quietHoursStartMinutes", fallbackHour: Int = 22): Int { + val direct = (settings[key] as? Number)?.toInt() + if (direct != null) return max(0, min(1439, direct)) + val fallbackKey = if (key == "quietHoursStartMinutes") "quietHoursStart" else "quietHoursEnd" + val hour = (settings[fallbackKey] as? Number)?.toDouble() ?: fallbackHour.toDouble() + return max(0, min(1439, (hour * 60).roundToInt())) +} +fun formatDecisionLabel(entry: Map): String { + val topic = (entry["topic"] ?: entry["key"] ?: "system").toString().replace("_", " ").replaceFirstChar { it.titlecase() } + val reason = entry["reason"]?.toString() + val suppressedReasonMap = mapOf("disabled_or_no_candidates" to "Nothing worth sending right now", "snoozed" to "Notifications are snoozed", "daily_cap" to "Daily cap reached", "weekly_cap" to "Weekly cap reached", "cooldown_or_topic_gate" to "Cooldown is active", "already_actioned" to "Action already completed", "permission_denied" to "Notification permission is off") + return when (entry["outcome"]?.toString()) { "suppressed" -> "SUPPRESSED - ${suppressedReasonMap[reason] ?: topic}"; "sent" -> "SCHEDULED - $topic"; else -> "${entry["outcome"]?.toString()?.uppercase() ?: "EVENT"} - $topic" } +} +fun formatDecisionTime(entry: Map): String = ((if (entry["outcome"] == "sent") entry["scheduledFor"] else entry["at"]) ?: "").toString().replace("T", " ").take(16) + + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/stats/ExerciseProgressScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/stats/ExerciseProgressScreen.kt new file mode 100644 index 0000000..900c909 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/stats/ExerciseProgressScreen.kt @@ -0,0 +1,577 @@ +package com.ironlog.app.ui.screens.stats + +import android.content.Intent +import androidx.core.content.FileProvider +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.outlined.FileDownload +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberColumnCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis +import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.core.cartesian.data.columnSeries +import com.patrykandpatrick.vico.core.cartesian.data.lineSeries +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ironlog.app.ui.viewmodel.ExerciseProgressViewModel +import com.ironlog.app.ui.viewmodel.ExerciseProgressViewModelFactory +import com.ironlog.app.services.ShareService +import com.ironlog.app.util.convertKgToUnit +import com.ironlog.app.util.formatVolumeFromKg +import com.ironlog.app.util.formatWeightFromKg +import java.time.Instant +import androidx.compose.runtime.rememberCoroutineScope +import com.ironlog.app.domain.gamification.parseHistoryInstant +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.WeekFields +import java.util.Locale +import java.io.File +import kotlin.math.round + +private val TABS = listOf("E1RM", "LOAD", "REPS", "VOLUME", "CONSISTENCY", "HISTORY") +private val RANGES = listOf("90D" to 90, "6M" to 180, "1Y" to 365, "ALL" to null) + +data class ExerciseTrendRow( + val date: String, + val e1rm: Double, + val load: Double, + val reps: Double, + val volume: Double, + val consistency: Double = 1.0, +) + +private data class HistorySessionRow( + val date: String, + val summary: String, + val bestSetStr: String?, + val volumeStr: String, +) + +private data class MetricConfig( + val label: String, + val unit: String, + val values: List, + val stat: Double, + val isBar: Boolean = false, +) + +fun filterExerciseRowsByRange(rows: List, days: Int?): List { + if (days == null) return rows + val cutoff = System.currentTimeMillis() - days * 86400000L + return rows.filter { runCatching { Instant.parse(it.date).toEpochMilli() >= cutoff }.getOrDefault(false) } +} + +fun roundMetric(value: Double, digits: Int = 1): Double { + if (!value.isFinite()) return 0.0 + val mult = Math.pow(10.0, digits.toDouble()) + return round(value * mult) / mult +} + +fun buildExerciseTrendLocal(history: List, exerciseName: String): List { + val target = exerciseName.lowercase(Locale.US).trim() + return history.flatMap { h -> + h.exercises.filter { it.name.lowercase(Locale.US).trim() == target }.mapNotNull { ex -> + val workingSets = ex.sets.filter { it.type != "warmup" } + if (workingSets.isEmpty()) null else { + val best = workingSets.maxByOrNull { it.weight * (1.0 + it.reps / 30.0) }!! + ExerciseTrendRow( + date = h.date, + e1rm = best.weight * (1.0 + best.reps / 30.0), + load = workingSets.maxOf { it.weight }, + reps = workingSets.maxOf { it.reps }, + volume = workingSets.sumOf { it.weight * it.reps }, + consistency = workingSets.size.toDouble(), + ) + } + } + }.sortedBy { it.date } +} + +private fun buildSessionHistoryRows( + history: List, + exerciseName: String, + weightUnit: String, +): List { + val target = exerciseName.lowercase(Locale.US).trim() + return history.mapNotNull { session -> + val exercise = session.exercises.find { it.name.lowercase(Locale.US).trim() == target } + ?: return@mapNotNull null + val workingSets = exercise.sets.filter { it.type != "warmup" } + val summary = if (workingSets.isEmpty()) "-" else + workingSets.joinToString(", ") { "${it.reps.toInt()}×${formatWeightFromKg(it.weight, weightUnit)}" } + val bestSet = workingSets.maxByOrNull { it.weight } + val bestSetStr = bestSet?.let { "${it.reps.toInt()} × ${formatWeightFromKg(it.weight, weightUnit)}" } + val volume = workingSets.sumOf { it.weight * it.reps } + HistorySessionRow( + date = session.date, + summary = summary, + bestSetStr = bestSetStr, + volumeStr = formatVolumeFromKg(volume, weightUnit), + ) + }.sortedByDescending { it.date } +} + +private fun computeMetricConfig(rows: List, activeTab: Int, weightUnit: String): MetricConfig = + when (TABS.getOrNull(activeTab)) { + "E1RM" -> { + val values = rows.map { convertKgToUnit(it.e1rm, weightUnit, 1) } + MetricConfig("BEST EST. 1RM", weightUnit, values, values.maxOrNull() ?: 0.0) + } + "LOAD" -> { + val values = rows.map { convertKgToUnit(it.load, weightUnit, 1) } + MetricConfig("TOP LOAD", weightUnit, values, values.maxOrNull() ?: 0.0) + } + "REPS" -> { + val values = rows.map { it.reps } + MetricConfig("AVG REPS/SET", "", values, + if (values.isEmpty()) 0.0 else values.average().let { if (it.isFinite()) it else 0.0 }) + } + "VOLUME" -> { + val values = rows.map { convertKgToUnit(it.volume, weightUnit, 0) } + MetricConfig("SESSION VOLUME", weightUnit, values, values.sumOf { it }, isBar = true) + } + "CONSISTENCY" -> { + // Group sessions by ISO week and count sessions per week + val zone = ZoneId.systemDefault() + val weekFields = WeekFields.ISO + val byWeek = rows.groupBy { row -> + val d = parseHistoryInstant(row.date)?.atZone(zone)?.toLocalDate() ?: return@groupBy "unknown" + "${d.get(weekFields.weekBasedYear())}-W${d.get(weekFields.weekOfWeekBasedYear()).toString().padStart(2, '0')}" + } + // Sort weeks chronologically and emit one value per week + val sortedWeeks = byWeek.keys.sorted() + val values = sortedWeeks.map { byWeek[it]!!.size.toDouble() } + val avgPerWeek = if (values.isEmpty()) 0.0 else values.average().let { if (it.isFinite()) it else 0.0 } + MetricConfig("SESSIONS / WEEK", "×", values, avgPerWeek, isBar = true) + } + else -> MetricConfig("", "", emptyList(), 0.0) + } + +// ── Main Screen ─────────────────────────────────────────────────────────────── + +@Composable +fun ExerciseProgressScreen( + exerciseName: String, + weightUnit: String = "kg", + onBack: () -> Unit = {}, + vm: ExerciseProgressViewModel = viewModel(factory = ExerciseProgressViewModelFactory(exerciseName)), +) { + val colors = useTheme() + val context = LocalContext.current + val csvScope = rememberCoroutineScope() + val history by vm.history.collectAsState() + var activeTab by remember { mutableIntStateOf(0) } + var range by remember { mutableStateOf("ALL") } + var showTrainingMax by remember { mutableStateOf(false) } + + val trend = remember(history, exerciseName) { buildExerciseTrendLocal(history, exerciseName) } + val rows = remember(trend, range) { + filterExerciseRowsByRange(trend, RANGES.first { it.first == range }.second) + } + val historyRows = remember(history, exerciseName, weightUnit) { + buildSessionHistoryRows(history, exerciseName, weightUnit) + } + val activeMetric = remember(rows, activeTab, weightUnit) { + computeMetricConfig(rows, activeTab, weightUnit) + } + + Column(Modifier.fillMaxSize().background(colors.bg).statusBarsPadding()) { + + // ── Header ────────────────────────────────────────────────────────── + Row( + Modifier + .fillMaxWidth() + .border(width = 1.dp, color = colors.faint, + shape = RoundedCornerShape(0.dp)) + .padding(horizontal = 4.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.Outlined.ArrowBack, contentDescription = "Back", tint = colors.text) + } + Text( + exerciseName, + color = colors.text, + fontWeight = FontWeight(IronLogType.section.fontWeight), + fontSize = IronLogType.section.fontSize.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + // TM Calculator button + TextButton(onClick = { showTrainingMax = true }) { + Text("CALC TM", color = colors.accent, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight.Black, letterSpacing = 1.sp) + } + // CSV export button + IconButton(onClick = { + val snapshot = rows.toList() + csvScope.launch { + val sb = StringBuilder("Date,Weight ($weightUnit),Reps,Volume ($weightUnit),e1RM ($weightUnit)\n") + snapshot.forEach { r -> + sb.append("${r.date.substringBefore('T')},") + sb.append("${convertKgToUnit(r.load, weightUnit)},") + sb.append("${r.reps.toInt()},") + sb.append("${convertKgToUnit(r.volume, weightUnit)},") + sb.append("${convertKgToUnit(r.e1rm, weightUnit)}\n") + } + val file = withContext(Dispatchers.IO) { + File(context.cacheDir, "ironlog_${exerciseName.replace(" ", "_")}.csv") + .also { it.writeText(sb.toString()) } + } + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + context.startActivity( + Intent(Intent.ACTION_SEND).apply { + type = "text/csv" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_SUBJECT, "IronLog – $exerciseName history") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }, + ) + } + }) { + Icon(Icons.Outlined.FileDownload, contentDescription = "Export CSV", tint = colors.accent) + } + // Share button + IconButton(onClick = { + val latest = rows.lastOrNull() + ShareService.shareMinimalCardImage( + context = context, + title = "Ironlog Exercise Progress", + headline = exerciseName, + metrics = listOf( + ShareService.ShareMetric("Best e1RM", latest?.let { formatWeightFromKg(it.e1rm, weightUnit) } ?: "-"), + ShareService.ShareMetric("Top load", latest?.let { formatWeightFromKg(it.load, weightUnit) } ?: "-"), + ShareService.ShareMetric("Sessions", rows.size.toString()), + ), + ) + }) { + Icon(Icons.Outlined.Share, contentDescription = "Share", tint = colors.accent) + } + } + + // ── Tab bar (underline indicator, no filter chips) ─────────────────── + Row( + Modifier + .fillMaxWidth() + .border(width = 1.dp, color = colors.faint, shape = RoundedCornerShape(0.dp)), + ) { + TABS.forEachIndexed { i, tab -> + val active = activeTab == i + Box( + modifier = Modifier + .weight(1f) + .clickable { activeTab = i } + .padding(vertical = 11.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + tab, + color = if (active) colors.accent else colors.muted, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = FontWeight(if (active) 800 else 600), + letterSpacing = 0.6.sp, + ) + Spacer(Modifier.height(6.dp)) + Box( + Modifier + .fillMaxWidth(0.6f) + .height(2.dp) + .background( + if (active) colors.accent else Color.Transparent, + RoundedCornerShape(1.dp), + ), + ) + } + } + } + } + + // ── Content ───────────────────────────────────────────────────────── + if (TABS[activeTab] == "HISTORY") { + if (historyRows.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No data for this exercise yet.", color = colors.muted, fontSize = IronLogType.body.fontSize.sp) + } + } else { + LazyColumn( + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(historyRows) { row -> SessionHistoryRow(row) } + } + } + } else { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Hero card + HeroMetricCard(activeMetric, rows.size) + + // Chart — bar for VOLUME, line for everything else + if (activeMetric.isBar) { + BarChartCard(activeMetric.values, rows.map { it.date }) + } else { + val prIndices = remember(rows, activeTab) { + if (TABS[activeTab] != "E1RM") emptySet() + else { + val prSet = mutableSetOf() + var maxSoFar = Double.MIN_VALUE + rows.forEachIndexed { i, row -> + if (row.e1rm > maxSoFar) { + maxSoFar = row.e1rm + prSet.add(i) + } + } + prSet + } + } + LineChartCard(activeMetric.values, rows.map { it.date }, prIndices) + } + + // Range selector + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + RANGES.forEach { (label, _) -> + FilterChip( + selected = range == label, + onClick = { range = label }, + label = { Text(label, fontSize = IronLogType.meta.fontSize.sp) }, + ) + } + } + + Spacer(Modifier.height(16.dp).navigationBarsPadding()) + } + } + } + + // ── Training Max dialog ───────────────────────────────────────────────── + if (showTrainingMax) { + val latest = rows.lastOrNull() + val baseKg = latest?.e1rm ?: 0.0 + AlertDialog( + onDismissRequest = { showTrainingMax = false }, + title = { Text("Training Max Calculator") }, + text = { + if (baseKg <= 0.0) { + Text("No e1RM data yet for $exerciseName.") + } else { + Text( + "Latest e1RM: ${formatWeightFromKg(baseKg, weightUnit)}\n" + + "85% TM: ${formatWeightFromKg(baseKg * 0.85, weightUnit)}\n" + + "90% TM: ${formatWeightFromKg(baseKg * 0.90, weightUnit)}\n" + + "95% TM: ${formatWeightFromKg(baseKg * 0.95, weightUnit)}", + ) + } + }, + confirmButton = { + TextButton(onClick = { showTrainingMax = false }) { Text("Done") } + }, + ) + } +} + +// ── Private composables ─────────────────────────────────────────────────────── + +@Composable +private fun HeroMetricCard(metric: MetricConfig, sessionCount: Int) { + val c = useTheme() + val statStr = if (metric.unit.isNotEmpty()) + "${roundMetric(metric.stat, if (metric.unit == "%") 0 else 1)} ${metric.unit}" + else + roundMetric(metric.stat, 1).toString() + Column( + Modifier + .fillMaxWidth() + .background(c.card, RoundedCornerShape(IronLogRadius.lg.dp)) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(metric.label, color = c.muted, fontSize = IronLogType.micro.fontSize.sp, letterSpacing = 2.6.sp) + Text(statStr, color = c.accent, fontWeight = FontWeight.Black, fontSize = IronLogType.display.fontSize.sp) + Text( + "$sessionCount session${if (sessionCount == 1) "" else "s"} in selected range", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } +} + +@Composable +private fun LineChartCard(values: List, dates: List, prIndices: Set = emptySet()) { + // prIndices (PR gold-ring markers) are not rendered — Vico 2.x decoration markers + // require significant custom Renderer work; dropped as acceptable trade-off. + val c = useTheme() + val modelProducer = remember { CartesianChartModelProducer() } + LaunchedEffect(values) { + if (values.isNotEmpty()) { + modelProducer.runTransaction { + lineSeries { series(y = values.map { it.toFloat() }) } + } + } + } + if (values.isEmpty()) { + Box( + Modifier + .fillMaxWidth() + .height(200.dp) + .background(c.card, RoundedCornerShape(IronLogRadius.lg.dp)) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)), + contentAlignment = Alignment.Center, + ) { Text("No data for this exercise yet.", color = c.muted) } + return + } + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(c.card) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)), + ) { + CartesianChartHost( + chart = rememberCartesianChart( + rememberLineCartesianLayer(), + startAxis = VerticalAxis.rememberStart(), + bottomAxis = HorizontalAxis.rememberBottom( + valueFormatter = CartesianValueFormatter { _, x, _ -> + dates.getOrElse(x.toInt()) { "" }.substringBefore('T') + } + ), + ), + modelProducer = modelProducer, + modifier = Modifier.fillMaxWidth().height(200.dp), + ) + } +} + +@Composable +private fun BarChartCard(values: List, dates: List) { + val c = useTheme() + val modelProducer = remember { CartesianChartModelProducer() } + LaunchedEffect(values) { + if (values.isNotEmpty()) { + modelProducer.runTransaction { + columnSeries { series(y = values.map { it.toFloat() }) } + } + } + } + if (values.isEmpty()) { + Box( + Modifier + .fillMaxWidth() + .height(200.dp) + .background(c.card, RoundedCornerShape(IronLogRadius.lg.dp)) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)), + contentAlignment = Alignment.Center, + ) { Text("No data for this exercise yet.", color = c.muted) } + return + } + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(c.card) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)), + ) { + CartesianChartHost( + chart = rememberCartesianChart( + rememberColumnCartesianLayer(), + startAxis = VerticalAxis.rememberStart(), + bottomAxis = HorizontalAxis.rememberBottom( + valueFormatter = CartesianValueFormatter { _, x, _ -> + dates.getOrElse(x.toInt()) { "" }.substringBefore('T') + } + ), + ), + modelProducer = modelProducer, + modifier = Modifier.fillMaxWidth().height(200.dp), + ) + } +} + +@Composable +private fun SessionHistoryRow(row: HistorySessionRow) { + val c = useTheme() + Row( + Modifier + .fillMaxWidth() + .background(c.card, RoundedCornerShape(IronLogRadius.lg.dp)) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + formatDateFull(row.date), + color = c.text, + fontWeight = FontWeight.Bold, + fontSize = IronLogType.body.fontSize.sp, + ) + Text(row.summary, color = c.muted, fontSize = IronLogType.meta.fontSize.sp, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text("Volume: ${row.volumeStr}", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp) + } + if (row.bestSetStr != null) { + Spacer(Modifier.width(12.dp)) + Column( + Modifier + .border(1.dp, c.accent, RoundedCornerShape(10.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("BEST", color = c.muted, fontSize = IronLogType.micro.fontSize.sp, letterSpacing = 2.sp) + Text(row.bestSetStr, color = c.accent, fontWeight = FontWeight.Black, fontSize = IronLogType.meta.fontSize.sp) + } + } + } +} + +private fun formatDateFull(dateStr: String): String = runCatching { + DateTimeFormatter.ofPattern("MMM d, yyyy") + .format(Instant.parse(dateStr).atZone(ZoneId.systemDefault())) +}.getOrDefault(dateStr) + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/stats/HistoryScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/stats/HistoryScreen.kt new file mode 100644 index 0000000..0294483 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/stats/HistoryScreen.kt @@ -0,0 +1,809 @@ +package com.ironlog.app.ui.screens.stats + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Assignment +import androidx.compose.material.icons.outlined.CameraAlt +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.ChevronRight +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Create +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Share +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ironlog.app.data.repository.HistoryRepository +import com.ironlog.app.ui.components.EmptyState +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.viewmodel.StatsViewModel +import com.ironlog.app.util.formatVolumeFromKg +import com.ironlog.app.util.formatWeightFromKg +import java.time.Instant +import com.ironlog.app.domain.gamification.parseHistoryInstant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +@OptIn(ExperimentalFoundationApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun HistoryScreen( + vm: StatsViewModel = viewModel(), + onOpenProgressPhotos: () -> Unit = {}, + onGoHome: () -> Unit = {}, +) { + val c = useTheme() + val context = LocalContext.current + val state by vm.state.collectAsState() + val scope = rememberCoroutineScope() + val historyRepository = remember { HistoryRepository() } + val weightUnit = state.weightUnit + var editingLog by remember { mutableStateOf(null) } + var searchQuery by remember { mutableStateOf("") } + var activeExerciseFilter by remember { mutableStateOf(null) } + var showExerciseFilterSheet by remember { mutableStateOf(false) } + // GAP-21: Bulk select/delete + var selectedIds by remember { mutableStateOf(setOf()) } + val isSelectionMode = selectedIds.isNotEmpty() + var showBulkDeleteConfirm by remember { mutableStateOf(false) } + // FIXED: 20 — confirmClearAll moved to SettingsScreen DATA & BACKUP section + + // Filter history by search query (matches workout name or any exercise name) + val filteredHistory = remember(state.history, searchQuery, activeExerciseFilter) { + var result = if (searchQuery.isBlank()) state.history + else { + val q = searchQuery.trim().lowercase() + state.history.filter { entry -> + entry.name.lowercase().contains(q) || + entry.exercises.any { it.name.lowercase().contains(q) } || + entry.date.substringBefore('T').contains(q) + } + } + activeExerciseFilter?.let { filter -> + result = result.filter { entry -> entry.exercises.any { it.name == filter } } + } + result + } + + val allExerciseNames = remember(state.history) { + state.history.flatMap { it.exercises.map { ex -> ex.name } }.distinct().sorted() + } + + LazyColumn(Modifier.fillMaxSize().background(c.bg).statusBarsPadding().padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 120.dp)) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "WORKOUT LOG", + color = c.accent, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + Text( + "History", + color = c.text, + fontWeight = FontWeight(IronLogType.display.fontWeight), + fontSize = IronLogType.title.fontSize.sp, + lineHeight = IronLogType.title.lineHeight.sp, + ) + Text( + if (searchQuery.isBlank()) "${state.history.size} session${if (state.history.size == 1) "" else "s"}" + else "${filteredHistory.size} of ${state.history.size} sessions", + color = c.muted, + fontSize = IronLogType.body.fontSize.sp, + ) + Spacer(Modifier.height(12.dp)) + // Search bar + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("Search workouts or exercises…", color = c.muted, fontSize = IronLogType.body.fontSize.sp) }, + leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null, tint = c.muted, modifier = Modifier.size(18.dp)) }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Outlined.Close, contentDescription = "Clear", tint = c.muted, modifier = Modifier.size(18.dp)) + } + } + }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + IconButton( + onClick = { showExerciseFilterSheet = true }, + modifier = Modifier + .size(48.dp) + .background( + if (activeExerciseFilter != null) c.accentSoft else c.surface, + androidx.compose.foundation.shape.RoundedCornerShape(IronLogRadius.md.dp) + ) + .border(1.dp, if (activeExerciseFilter != null) c.accentBorder else c.cardBorder, androidx.compose.foundation.shape.RoundedCornerShape(IronLogRadius.md.dp)), + ) { + Icon( + Icons.Outlined.FilterList, + contentDescription = "Filter by exercise", + tint = if (activeExerciseFilter != null) c.accent else c.muted, + modifier = Modifier.size(20.dp) + ) + } + } + // Active exercise filter chip + activeExerciseFilter?.let { filter -> + Spacer(Modifier.height(4.dp)) + Row( + modifier = Modifier + .clip(androidx.compose.foundation.shape.RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.accentSoft) + .border(1.dp, c.accentBorder, androidx.compose.foundation.shape.RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { activeExerciseFilter = null } + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(filter, color = c.accent, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Medium) + Icon(Icons.Outlined.Close, contentDescription = "Remove filter", tint = c.accent, modifier = Modifier.size(14.dp)) + } + } + Spacer(Modifier.height(8.dp)) + // Progress Photos — styled card row + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.md.dp)) + .background(c.card) + .clickable { onOpenProgressPhotos() } + .padding(horizontal = 14.dp, vertical = 13.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon(Icons.Outlined.CameraAlt, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp)) + Text("PROGRESS PHOTOS", color = c.text, fontWeight = FontWeight(IronLogType.body.fontWeight), fontSize = IronLogType.body.fontSize.sp, letterSpacing = 0.8.sp) + } + Icon(Icons.Outlined.ChevronRight, contentDescription = null, tint = c.muted, modifier = Modifier.size(16.dp)) + } + Spacer(Modifier.height(4.dp)) + } + if (filteredHistory.isEmpty()) { + item { + EmptyState( + icon = Icons.AutoMirrored.Outlined.Assignment, + headline = if (searchQuery.isBlank()) "No workouts yet" else "No results for \"$searchQuery\"", + body = if (searchQuery.isBlank()) "Start your first session to see your history here." else "Try a different search term.", + ctaLabel = if (searchQuery.isBlank()) "GO TO TODAY" else "CLEAR SEARCH", + onCta = if (searchQuery.isBlank()) onGoHome else { { searchQuery = "" } }, + ) + } + } + // FIXED: 18 — month-grouped sticky headers + val grouped = filteredHistory.groupBy { entry -> + val instant = parseHistoryInstant(entry.date) ?: Instant.EPOCH + if (instant == Instant.EPOCH) "Unknown" + else DateTimeFormatter.ofPattern("MMMM yyyy").withZone(ZoneId.systemDefault()).format(instant) + } + grouped.forEach { (monthLabel, unsortedEntries) -> + val entries = unsortedEntries.sortedByDescending { it.date } + stickyHeader(key = "header_$monthLabel") { + Box(Modifier.fillMaxWidth().background(c.bg).padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + monthLabel.uppercase(), + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + } + } + itemsIndexed(entries, key = { _, h -> h.id }) { index, h -> + val previousVolume = entries.getOrNull(index + 1)?.volume + val isSelected = h.id in selectedIds + HistoryCard( + h = h, + weightUnit = weightUnit, + previousVolume = previousVolume, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + onTap = { + if (isSelectionMode) { + selectedIds = if (isSelected) selectedIds - h.id else selectedIds + h.id + } else { + editingLog = h + } + }, + onLongPress = { + selectedIds = selectedIds + h.id + }, + onShare = if (isSelectionMode) null else { shareText -> + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, shareText) + } + context.startActivity(Intent.createChooser(intent, "Share workout")) + }, + ) + } + } + if (state.history.isNotEmpty()) { + item { Spacer(Modifier.height(88.dp)) } + } + } + + // GAP-21: Floating bulk-delete bar + if (isSelectionMode) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + Row( + Modifier + .fillMaxWidth() + .background(c.danger) + .navigationBarsPadding() + .padding(horizontal = 20.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = { selectedIds = emptySet() }) { + Text("CANCEL", color = c.textOnAccent, fontWeight = FontWeight.Bold, fontSize = IronLogType.body.fontSize.sp) + } + Text( + "${selectedIds.size} selected", + color = c.textOnAccent, + fontWeight = FontWeight.Bold, + fontSize = IronLogType.body.fontSize.sp, + ) + TextButton(onClick = { showBulkDeleteConfirm = true }) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(Icons.Filled.Delete, contentDescription = null, tint = c.textOnAccent, modifier = Modifier.size(18.dp)) + Text("DELETE (${selectedIds.size})", color = c.textOnAccent, fontWeight = FontWeight.Bold, fontSize = IronLogType.body.fontSize.sp) + } + } + } + } + } + + // GAP-21: Bulk-delete confirmation dialog + if (showBulkDeleteConfirm) { + AlertDialog( + onDismissRequest = { showBulkDeleteConfirm = false }, + title = { Text("Delete ${selectedIds.size} workout${if (selectedIds.size == 1) "" else "s"}?", color = c.text) }, + text = { Text("This cannot be undone.", color = c.subtext) }, + confirmButton = { + TextButton(onClick = { + val toDelete = selectedIds.toList() + showBulkDeleteConfirm = false + scope.launch { + try { + historyRepository.deleteWorkouts(toDelete) + vm.refresh() + } finally { + // Always clear selection — even if delete throws — + // so the selection mode is never permanently stuck. + selectedIds = emptySet() + } + } + }) { + Text("DELETE", color = c.danger, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = { showBulkDeleteConfirm = false }) { + Text("CANCEL", color = c.muted) + } + }, + containerColor = c.card, + ) + } + + editingLog?.let { entry -> + EditHistoryPanel( + entry, + onClose = { editingLog = null }, + onSave = { updated -> + scope.launch { + historyRepository.updateWorkout( + uid = updated.id, + startedAt = parseHistoryDateTimeMillis(updated.date), + durationSeconds = updated.duration, + rating = updated.rating, + notes = updated.summaryText, + ) + editingLog = null + vm.refresh() + } + }, + onDelete = { + scope.launch { + historyRepository.deleteWorkout(entry.id) + editingLog = null + vm.refresh() + } + }, + ) + } + + // FIXED: 20 — Clear All History AlertDialog moved to SettingsScreen + + if (showExerciseFilterSheet) { + androidx.compose.material3.ModalBottomSheet( + onDismissRequest = { showExerciseFilterSheet = false }, + containerColor = c.card, + contentColor = c.text, + ) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + "FILTER BY EXERCISE", + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + Spacer(Modifier.height(8.dp)) + if (allExerciseNames.isEmpty()) { + Text("No exercises in history yet.", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + } else { + allExerciseNames.forEach { name -> + Row( + Modifier + .fillMaxWidth() + .clickable { + activeExerciseFilter = name + showExerciseFilterSheet = false + } + .padding(vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(name, color = c.text, fontSize = IronLogType.body.fontSize.sp) + if (activeExerciseFilter == name) { + Icon(Icons.Outlined.Close, contentDescription = null, tint = c.accent, modifier = Modifier.size(16.dp)) + } + } + } + } + } + } + } +} + +@Composable +private fun HistoryCard( + h: HistoryEntry, + weightUnit: String, + previousVolume: Double? = null, + isSelected: Boolean = false, + isSelectionMode: Boolean = false, + onTap: () -> Unit, + onLongPress: (() -> Unit)? = null, + onShare: ((String) -> Unit)? = null, +) { + val c = useTheme() + val instant = runCatching { Instant.parse(h.date) }.getOrDefault(Instant.now()) + val dateStr = DateTimeFormatter.ofPattern("dd MMM yy").withZone(ZoneId.systemDefault()).format(instant) + val timeStr = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault()).format(instant) + val volumeDelta = previousVolume?.let { h.volume - it } + + // FIXED: 16, 38 — Tonal background (no left bar per step 38 audit); IntrinsicSize.Min retained + Row( + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(if (isSelected) c.accentSoft else c.card) + .border(1.dp, if (isSelected) c.accent else c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .combinedClickable( + onClick = onTap, + onLongClick = onLongPress, + ), + ) { + // Card body (no left bar — date shown top-right per step 38) + Column( + Modifier + .weight(1f) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { + // Day name / workout name in accent + Text( + (h.name).uppercase(), + color = c.accent, + fontWeight = FontWeight(IronLogType.section.fontWeight), + fontSize = IronLogType.section.fontSize.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + "${h.sets} sets · ${h.duration / 60}min", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + // Volume + delta + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + formatVolumeFromKg(h.volume, weightUnit), + color = c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + ) + if (volumeDelta != null) { + val sign = if (volumeDelta >= 0) "+" else "" + // FIXED: 21 — use theme tokens instead of raw Color + Text( + "$sign${formatVolumeFromKg(abs(volumeDelta), weightUnit)} vs prev", + color = if (volumeDelta >= 0) c.success else c.danger, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } + } + Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (isSelectionMode) { + Box( + Modifier + .size(22.dp) + .background( + if (isSelected) c.accent else c.surface, + CircleShape + ) + .border(1.5.dp, if (isSelected) c.accent else c.cardBorder, CircleShape), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + Icons.Filled.CheckCircle, + contentDescription = "Selected", + tint = c.textOnAccent, + modifier = Modifier.size(14.dp), + ) + } + } + } else { + Column(horizontalAlignment = Alignment.End) { + Text(dateStr, color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + Text(timeStr, color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + } + if (onShare != null) { + IconButton( + onClick = { + val exerciseLines = h.exercises.joinToString("\n") { ex -> + val setsSummary = ex.sets.joinToString(", ") { s -> + val w = if (s.weight > 0) formatWeightFromKg(s.weight, weightUnit) else "BW" + "${w}×${s.reps.roundToInt()}" + } + " ${ex.name}: $setsSummary" + } + val shareText = buildString { + append("🏋️ ${h.name} — $dateStr $timeStr\n") + append("${h.sets} sets · ${h.duration / 60}min · ${formatVolumeFromKg(h.volume, weightUnit)}\n") + if (exerciseLines.isNotEmpty()) append("\n$exerciseLines\n") + h.summaryText?.let { append("\n$it\n") } + append("\nLogged with IronLog") + } + onShare(shareText) + }, + modifier = Modifier.size(32.dp), + ) { + Icon(Icons.Outlined.Share, contentDescription = "Share workout", tint = c.muted, modifier = Modifier.size(16.dp)) + } + } + } + } + // Exercise breakdown: all exercises with set details + if (h.exercises.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + h.exercises.forEach { ex -> + val setsSummary = ex.sets.joinToString(", ") { s -> + val w = if (s.weight > 0) formatWeightFromKg(s.weight, weightUnit) else "BW" + "${w}×${s.reps.roundToInt()}" + } + Text( + "${ex.name}: $setsSummary", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + } + } + h.summaryText?.let { + Text(it, color = c.muted, fontSize = IronLogType.meta.fontSize.sp, maxLines = 2, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis) + } + // FIXED: 19 — removed "Tap to edit" hint; card is tappable by its visible treatment + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun EditHistoryPanel( + entry: HistoryEntry, + onClose: () -> Unit, + onSave: (HistoryEntry) -> Unit, + onDelete: () -> Unit = {}, +) { + val c = useTheme() + var date by remember(entry.id) { mutableStateOf(entry.date.substringBefore('T')) } + var time by remember(entry.id) { mutableStateOf(entry.date.substringAfter('T', "00:00").take(5)) } + var durationMinutes by remember(entry.id) { mutableStateOf(max(0, (entry.duration / 60.0).roundToInt()).toString()) } + var rating by remember(entry.id) { mutableStateOf(entry.rating?.toString().orEmpty()) } + var summaryText by remember(entry.id) { mutableStateOf(entry.summaryText.orEmpty()) } + var confirmDelete by remember { mutableStateOf(false) } + var dateTimeError by remember(entry.id) { mutableStateOf(null) } + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onClose, + containerColor = c.card, + title = { Text("EDIT LOG", color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.section.fontSize.sp) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = date, + onValueChange = { date = it; dateTimeError = null }, + label = { Text("YYYY-MM-DD") }, + isError = dateTimeError != null, + trailingIcon = { + IconButton(onClick = { showDatePicker = true }) { + Icon(Icons.Outlined.CalendarMonth, contentDescription = "Pick date") + } + }, + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = time, + onValueChange = { time = it; dateTimeError = null }, + label = { Text("HH:MM") }, + isError = dateTimeError != null, + trailingIcon = { + Text( + "PICK", + color = c.accent, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.clickable { showTimePicker = true }.padding(end = 8.dp), + ) + }, + modifier = Modifier.weight(1f), + ) + } + dateTimeError?.let { + Text(it, color = c.danger, fontSize = IronLogType.meta.fontSize.sp) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField(durationMinutes, { durationMinutes = it }, label = { Text("MIN") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f)) + // Star rating selector + Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) { + Text("Rating", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + val currentRating = rating.toIntOrNull() ?: 0 + (1..5).forEach { star -> + Text( + text = if (star <= currentRating) "★" else "☆", + color = if (star <= currentRating) c.warning else c.muted, + fontSize = 22.sp, + modifier = Modifier.clickable { rating = star.toString() }, + ) + } + } + } + } + OutlinedTextField(summaryText, { summaryText = it }, label = { Text("Summary") }, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(4.dp)) + // DELETE LOG — destructive, prominent + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.md.dp)) + .background(c.danger.copy(alpha = 0.10f)) + .clickable { confirmDelete = true } + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "DELETE LOG", + color = c.danger, + fontWeight = FontWeight(800), + fontSize = IronLogType.body.fontSize.sp, + letterSpacing = 1.sp, + ) + } + } + }, + confirmButton = { + Button( + onClick = { + val mergedIso = parseHistoryEditTimestampOrNull(date.trim(), time.trim()) + if (mergedIso == null) { + dateTimeError = "Pick or enter a valid date and 24-hour time." + return@Button + } + onSave( + entry.copy( + date = mergedIso, + duration = (durationMinutes.toDoubleOrNull() ?: 0.0).roundToInt() * 60, + rating = rating.toDoubleOrNull()?.takeIf { it > 0 }?.let { min(5.0, max(1.0, it)) }, + summaryText = summaryText.trim().ifBlank { null }, + ), + ) + }, + colors = ButtonDefaults.buttonColors(containerColor = c.accent), + ) { Text("SAVE") } + }, + dismissButton = { + TextButton(onClick = onClose) { Text("CANCEL", color = c.muted) } + }, + ) + + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + containerColor = c.card, + title = { Text("Delete this workout?", color = c.text) }, + text = { Text("This cannot be undone.", color = c.muted) }, + confirmButton = { + TextButton(onClick = { confirmDelete = false; onDelete() }) { + Text("DELETE", color = c.danger, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = { confirmDelete = false }) { Text("CANCEL", color = c.muted) } + }, + ) + } + + if (showDatePicker) { + val initialMillis = remember(date) { + runCatching { + LocalDate.parse(date) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + }.getOrDefault(parseHistoryDateTimeMillis(entry.date)) + } + val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialMillis) + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + datePickerState.selectedDateMillis?.let { millis -> + date = Instant.ofEpochMilli(millis) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toString() + dateTimeError = null + } + showDatePicker = false + }) { Text("SET", color = c.accent) } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { Text("CANCEL", color = c.muted) } + }, + ) { + DatePicker(state = datePickerState) + } + } + + if (showTimePicker) { + val currentTime = runCatching { LocalTime.parse(time) }.getOrDefault(LocalTime.MIDNIGHT) + val timePickerState = rememberTimePickerState( + initialHour = currentTime.hour, + initialMinute = currentTime.minute, + is24Hour = true, + ) + AlertDialog( + onDismissRequest = { showTimePicker = false }, + containerColor = c.card, + title = { Text("Pick time", color = c.text) }, + text = { TimePicker(state = timePickerState) }, + confirmButton = { + TextButton(onClick = { + time = "%02d:%02d".format(timePickerState.hour, timePickerState.minute) + dateTimeError = null + showTimePicker = false + }) { Text("SET", color = c.accent) } + }, + dismissButton = { + TextButton(onClick = { showTimePicker = false }) { Text("CANCEL", color = c.muted) } + }, + ) + } +} + +internal fun parseHistoryEditTimestampOrNull(date: String, time: String): String? = + runCatching { + val parsedDate = LocalDate.parse(date.trim()) + val parsedTime = LocalTime.parse(time.trim()) + LocalDateTime.of(parsedDate, parsedTime) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toString() + }.getOrNull() + +private fun parseHistoryDateTimeMillis(iso: String): Long { + val existing = runCatching { Instant.parse(iso).toEpochMilli() }.getOrNull() + if (existing != null) return existing + return runCatching { + val local = LocalDateTime.parse(iso.replace(" ", "T")) + local.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + }.getOrElse { System.currentTimeMillis() } +} + + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/stats/StatsScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/stats/StatsScreen.kt new file mode 100644 index 0000000..3d40f43 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/stats/StatsScreen.kt @@ -0,0 +1,969 @@ +package com.ironlog.app.ui.screens.stats + +import android.app.Application +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Analytics +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.Cloud +import androidx.compose.material.icons.outlined.MonitorWeight +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.TextButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.material3.AlertDialog +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.domain.intelligence.CloudAiEngine +import com.ironlog.app.domain.intelligence.CloudAiKeyStore +import com.ironlog.app.ui.components.PageHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.viewmodel.GamificationUiState +import com.ironlog.app.ui.viewmodel.GamificationViewModel +import com.ironlog.app.ui.viewmodel.GamificationViewModelFactory +import com.ironlog.app.ui.viewmodel.AppDataViewModel +import com.ironlog.app.ui.viewmodel.StatsViewModel +import com.ironlog.app.util.formatWeightFromKg +import com.valentinilk.shimmer.shimmer +import java.time.LocalDate +import java.time.ZoneId +import java.time.Instant +import com.ironlog.app.domain.gamification.parseHistoryInstant +import kotlin.math.round +import kotlinx.coroutines.launch +import com.ironlog.app.ui.screens.home.getStreak + +@Composable +fun StatsScreen( + vm: StatsViewModel = viewModel(), + onOpenExerciseProgress: (String) -> Unit = {}, + onOpenCalendar: () -> Unit = {}, + onOpenBodyTracker: () -> Unit = {}, + onOpenVolumeAnalytics: () -> Unit = {}, + onOpenRecoveryMap: () -> Unit = {}, + onOpenTrainingIntelligence: () -> Unit = {}, + onOpenProgressPhotos: () -> Unit = {}, + onOpenStatusWindow: () -> Unit = {}, +) { + val c = useTheme() + val state by vm.state.collectAsState() + val pbEntries = state.personalBests + val scope = rememberCoroutineScope() + var showClearPbsConfirm by remember { mutableStateOf(false) } + var pbShowLimit by remember { mutableIntStateOf(25) } + + val appVm: AppDataViewModel = viewModel() + val appState by appVm.state.collectAsState() + val cloudSettings = appState.settings + val context = LocalContext.current + val gamificationVm: GamificationViewModel = viewModel( + factory = GamificationViewModelFactory( + context.applicationContext as Application, + ObjectBox.store, + ) + ) + val gamState by gamificationVm.uiState.collectAsState() + LaunchedEffect(appState.history, appState.settings.weeklyGoalDays) { + gamificationVm.refreshFromHistory(appState.history, appState.settings.weeklyGoalDays) + } + val cloudApiKey = remember(cloudSettings.cloudAiProviderPreset) { + CloudAiKeyStore.load(context, cloudSettings.cloudAiProviderPreset) + } + val cloudConfigured = cloudApiKey.isNotBlank() + && cloudSettings.cloudAiBaseUrl.isNotBlank() + && cloudSettings.cloudAiModelName.isNotBlank() + + var statsSummaryText by remember { mutableStateOf(null) } + var statsSummaryLoading by remember { mutableStateOf(cloudConfigured) } + var statsSummaryVersion by remember { mutableIntStateOf(0) } + + LaunchedEffect(statsSummaryVersion) { + if (!cloudConfigured) return@LaunchedEffect + statsSummaryLoading = true + val topExercise = state.history + .flatMap { it.exercises } + .groupingBy { it.name } + .eachCount() + .maxByOrNull { it.value } + ?.key ?: "your main lift" + statsSummaryText = CloudAiEngine.askStatsSummary( + baseUrl = cloudSettings.cloudAiBaseUrl, + apiKey = cloudApiKey, + modelName = cloudSettings.cloudAiModelName, + apiFormat = cloudSettings.cloudAiApiFormat, + totalSessions = state.history.size, + streak = state.streak, + totalSets = state.totalSets, + avgDurationMin = state.avgDurationMin, + topExercise = topExercise, + weightUnit = cloudSettings.weightUnit, + ) + statsSummaryLoading = false + } + var chartRange by remember { mutableStateOf("14D") } + val rangedChartPoints = remember(state.history, chartRange) { + val datestamps = state.history.map { it.date.substringBefore('T') } + val counts = datestamps.groupingBy { it }.eachCount() + val formatter = java.time.format.DateTimeFormatter.ofPattern("dd/MM") + when (chartRange) { + "30D" -> { + val labelIndices = setOf(0, 9, 19, 29) + (0 until 30).map { i -> + val day = java.time.LocalDate.now().minusDays((29 - i).toLong()) + com.ironlog.app.ui.model.ChartPoint(value = counts[day.toString()] ?: 0, label = if (i in labelIndices) day.format(formatter) else "") + } + } + "90D" -> { + val labelIndices = setOf(0, 29, 59, 89) + (0 until 90).map { i -> + val day = java.time.LocalDate.now().minusDays((89 - i).toLong()) + com.ironlog.app.ui.model.ChartPoint(value = counts[day.toString()] ?: 0, label = if (i in labelIndices) day.format(formatter) else "") + } + } + "All" -> { + if (datestamps.isEmpty()) emptyList() + else { + val earliest = datestamps.minOrNull()?.let { java.time.LocalDate.parse(it) } ?: java.time.LocalDate.now() + val totalDays = java.time.temporal.ChronoUnit.DAYS.between(earliest, java.time.LocalDate.now()).toInt() + 1 + val step = maxOf(1, totalDays / 30) + (0 until totalDays step step).map { i -> + val day = earliest.plusDays(i.toLong()) + val windowCount = (0 until step).sumOf { offset -> counts[earliest.plusDays((i + offset).toLong()).toString()] ?: 0 } + com.ironlog.app.ui.model.ChartPoint(value = windowCount, label = if (i == 0 || i >= totalDays - step) day.format(formatter) else "") + } + } + } + else -> state.chartData // "14D" — use pre-built + } + } + + LazyColumn( + Modifier.fillMaxSize().background(c.bg).statusBarsPadding(), + verticalArrangement = Arrangement.spacedBy(14.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 120.dp), + ) { + // FIXED: 6 + 22 — PageHeader for tab screen + item { + PageHeader( + eyebrow = "ANALYTICS", + title = "Stats", + subtitle = "${state.history.size} sessions · ${state.streak}-day streak", + ) + } + item { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + QuickNavButton("CALENDAR", Icons.Outlined.CalendarMonth, Modifier.weight(1f), onOpenCalendar) + QuickNavButton("VOLUME", Icons.Outlined.Analytics, Modifier.weight(1f), onOpenVolumeAnalytics) + QuickNavButton("BODY", Icons.Outlined.MonitorWeight, Modifier.weight(1f), onOpenBodyTracker) + } + } + item { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + ) { + StatCard("Sessions", state.history.size.toString(), Modifier.weight(1f)) + StatCard("Streak", state.streak.toString(), Modifier.weight(1f)) + StatCard("Sets", state.totalSets.toString(), Modifier.weight(1f)) + StatCard("Avg Min", state.avgDurationMin.toString(), Modifier.weight(1f)) + } + } + item { + StatusWindowStatsCard( + state = gamState, + totalSessions = state.history.size, + onOpen = onOpenStatusWindow, + ) + } + // ── Cloud AI stats summary ──────────────────────────────────────── + if (cloudConfigured) { + item { + AiStatsSummaryCard( + isLoading = statsSummaryLoading, + text = statsSummaryText, + displayName = cloudSettings.cloudAiDisplayName, + onRegenerate = { statsSummaryVersion++ }, + ) + } + } + item { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) { + Text("PERFORMANCE STATS", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, fontWeight = FontWeight(IronLogType.eyebrow.fontWeight)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(top = 10.dp)) { + MiniAction("RECOVERY", Modifier.weight(1f), onOpenRecoveryMap) + CoachMiniAction(Modifier.weight(1f), onOpenTrainingIntelligence) + MiniAction("PROGRESS", Modifier.weight(1f), onOpenProgressPhotos) + } + } + } + item { + Card( + shape = RoundedCornerShape(IronLogRadius.xl.dp), + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "14-DAY FREQUENCY", + color = c.text, + fontWeight = FontWeight(IronLogType.section.fontWeight), + fontSize = IronLogType.section.fontSize.sp, + ) + FrequencyBars( + history = state.history, + barColor = c.accent, + trackColor = c.cardBorder, + labelColor = c.muted, + ) + } + } + } + item { + Card( + shape = RoundedCornerShape(IronLogRadius.xl.dp), + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text( + when (chartRange) { "14D" -> "LAST 14 DAYS"; "30D" -> "LAST 30 DAYS"; "90D" -> "LAST 90 DAYS"; else -> "ALL TIME" }, + color = c.text, + fontWeight = FontWeight(IronLogType.section.fontWeight), + fontSize = IronLogType.section.fontSize.sp, + ) + Text("sessions / day", color = c.muted, fontSize = IronLogType.micro.fontSize.sp) + } + // Time-range filter chips (GAP-02) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + listOf("14D", "30D", "90D", "All").forEach { range -> + val active = chartRange == range + Box( + modifier = Modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(if (active) c.accent.copy(alpha = 0.18f) else c.surface) + .border(1.dp, if (active) c.accent else c.cardBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { chartRange = range } + .padding(horizontal = 10.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + range, + color = if (active) c.accent else c.subtext, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = if (active) FontWeight.Bold else FontWeight.Normal, + ) + } + } + } + if (rangedChartPoints.isEmpty()) { + Text( + "No sessions logged yet. Start training to see your chart.", + color = c.muted, + fontSize = IronLogType.body.fontSize.sp, + ) + } else { + // Chart with Y-axis labels on left and X-axis date labels below + val maxVal = rangedChartPoints.maxOf { it.value.toFloat() }.coerceAtLeast(1f) + // Round up to a clean axis tick (1, 2, 4, 5, 10, 20, ...) + val yMax = niceAxisMax(maxVal) + Row( + modifier = Modifier.fillMaxWidth().height(140.dp), + verticalAlignment = Alignment.Top, + ) { + // Y-axis labels (left column) + Column( + modifier = Modifier.width(28.dp).fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.End, + ) { + listOf(yMax, yMax * 0.75f, yMax * 0.5f, yMax * 0.25f, 0f).forEach { v -> + Text( + formatAxisValue(v), + color = c.muted, + fontSize = IronLogType.micro.fontSize.sp, + ) + } + } + Spacer(Modifier.width(6.dp)) + // The chart itself + SessionLineChart( + points = rangedChartPoints, + lineColor = c.chartPrimary, + dotColor = c.accent, + gridColor = c.faint, + yMax = yMax, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + } + // X-axis date labels (first, middle, last) + Row( + Modifier.fillMaxWidth().padding(start = 34.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + val firstLabel = rangedChartPoints.firstOrNull()?.label.orEmpty() + val midIdx = rangedChartPoints.size / 2 + val midLabel = rangedChartPoints.getOrNull(midIdx)?.label.orEmpty() + val lastLabel = rangedChartPoints.lastOrNull()?.label.orEmpty() + listOf(firstLabel, midLabel, lastLabel).forEach { lbl -> + if (lbl.isNotBlank()) { + Text(lbl, color = c.muted, fontSize = IronLogType.micro.fontSize.sp) + } else { + Spacer(Modifier.width(1.dp)) + } + } + } + } + } + } + } + item { + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + "Personal Bests", + color = c.text, + fontWeight = FontWeight(IronLogType.title.fontWeight), + fontSize = IronLogType.title.fontSize.sp, + ) + TextButton(onClick = { showClearPbsConfirm = true }) { Text("Clear") } + } + } + if (pbEntries.isEmpty()) { + item { + Spacer(Modifier.height(8.dp)) + Text( + "No PRs yet. Log your first workout and claim them.", + color = c.muted, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + items(pbEntries.take(pbShowLimit), key = { it.exerciseName }) { pb -> + Card( + shape = RoundedCornerShape(IronLogRadius.lg.dp), + colors = CardDefaults.cardColors(containerColor = c.surface), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).clickable { onOpenExerciseProgress(pb.exerciseName) }, + ) { + Row( + Modifier.fillMaxWidth().padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + // FIXED: 26 — enriched PB: date + trend delta + androidx.compose.foundation.layout.Column( + Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + pb.exerciseName, + color = c.text, + fontWeight = FontWeight.Bold, + fontSize = IronLogType.body.fontSize.sp, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + val dateLabel = pb.date?.let { "Set $it" } + val prevLabel = pb.previousOrm?.let { "from ~${formatWeightFromKg(it, state.weightUnit)}" } + val metaLine = listOfNotNull(dateLabel, prevLabel).joinToString(" · ") + if (metaLine.isNotBlank()) { + Text(metaLine, color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + Text( + "Est. 1RM: ~${formatWeightFromKg(pb.estOneRm, state.weightUnit)}", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + // Right: PR weight + trend arrow + androidx.compose.foundation.layout.Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + "PR ${formatWeightFromKg(pb.bestWeight, state.weightUnit)}", + color = c.accent, + fontWeight = FontWeight(IronLogType.section.fontWeight), + fontSize = IronLogType.body.fontSize.sp, + ) + pb.previousOrm?.let { prevOrm -> + val delta = pb.estOneRm - prevOrm + val sign = if (delta >= 0) "↑ +" else "↓ " + Text( + "$sign${formatWeightFromKg(kotlin.math.abs(delta), state.weightUnit)}", + color = if (delta >= 0) c.success else c.danger, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } + // Show more / show less for PBs + if (pbEntries.size > pbShowLimit) { + item { + androidx.compose.foundation.layout.Box( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + contentAlignment = androidx.compose.ui.Alignment.Center, + ) { + TextButton(onClick = { pbShowLimit += 25 }) { + Text("SHOW MORE (${pbEntries.size - pbShowLimit} remaining)", color = c.accent, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + } else if (pbShowLimit > 25 && pbEntries.isNotEmpty()) { + item { + androidx.compose.foundation.layout.Box( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + contentAlignment = androidx.compose.ui.Alignment.Center, + ) { + TextButton(onClick = { pbShowLimit = 25 }) { + Text("SHOW LESS", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + } + item { + val topExercises = remember(state.history, state.weightUnit) { + state.history + .flatMap { entry -> entry.exercises } + .groupBy { it.name } + .mapValues { (_, exercises) -> + exercises.sumOf { ex -> ex.sets.filter { it.type != "warmup" }.sumOf { it.weight * it.reps } } + } + .entries + .sortedByDescending { it.value } + .take(5) + } + if (topExercises.isNotEmpty()) { + Card( + shape = RoundedCornerShape(IronLogRadius.lg.dp), + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + "Top Exercises by Volume", + color = c.text, + fontWeight = FontWeight(IronLogType.title.fontWeight), + fontSize = IronLogType.title.fontSize.sp, + ) + topExercises.forEachIndexed { index, (name, totalKg) -> + Row( + Modifier.fillMaxWidth().clickable { onOpenExerciseProgress(name) }, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${index + 1}", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.width(20.dp), + ) + Text( + name, + color = c.text, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + Text( + formatWeightFromKg(totalKg, state.weightUnit), + color = c.accent, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } + } + } + + if (showClearPbsConfirm) { + AlertDialog( + onDismissRequest = { showClearPbsConfirm = false }, + containerColor = c.card, + title = { Text("Clear Personal Bests?", color = c.text) }, + text = { Text("This will permanently remove all PR records. This cannot be undone.", color = c.muted) }, + confirmButton = { + TextButton(onClick = { + scope.launch { vm.clearPbs() } + showClearPbsConfirm = false + }) { Text("CLEAR ALL", color = c.danger, fontWeight = FontWeight.Bold) } + }, + dismissButton = { + TextButton(onClick = { showClearPbsConfirm = false }) { Text("CANCEL", color = c.muted) } + }, + ) + } +} + +@Composable +private fun AiStatsSummaryCard( + isLoading: Boolean, + text: String?, + displayName: String, + onRegenerate: () -> Unit, +) { + val c = useTheme() + val cardBg = c.accent.copy(alpha = 0.07f) + val accentBorder = c.accent.copy(alpha = 0.25f) + + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(cardBg) + .border(1.dp, accentBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(Icons.Outlined.Cloud, contentDescription = null, tint = c.accent, modifier = Modifier.size(13.dp)) + Text( + displayName.uppercase().ifBlank { "CLOUD AI" }, + color = c.accent, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + } + if (isLoading) { + Column(Modifier.shimmer(), verticalArrangement = Arrangement.spacedBy(5.dp)) { + repeat(2) { idx -> + Box( + Modifier + .fillMaxWidth(if (idx == 1) 0.65f else 1f) + .height(13.dp) + .clip(RoundedCornerShape(4.dp)) + .background(c.faint) + ) + } + } + } else { + AnimatedContent( + targetState = text, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "stats_summary", + ) { t -> + Text(t ?: "", color = c.text, fontSize = IronLogType.body.fontSize.sp, lineHeight = IronLogType.body.lineHeight.sp) + } + Row( + Modifier.clickable(onClick = onRegenerate).padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon(Icons.Outlined.Refresh, null, tint = c.muted, modifier = Modifier.size(11.dp)) + Text("Regenerate", color = c.muted, fontSize = IronLogType.micro.fontSize.sp) + } + } + } +} + +@Composable +private fun StatusWindowStatsCard( + state: GamificationUiState, + totalSessions: Int, + onOpen: () -> Unit, +) { + val c = useTheme() + val xpFraction = if (state.xpForNextLevel > 0L) { + (state.xpInLevel.toFloat() / state.xpForNextLevel.toFloat()).coerceIn(0f, 1f) + } else 0f + + Card( + shape = RoundedCornerShape(IronLogRadius.xl.dp), + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.accent.copy(alpha = 0.36f)), + modifier = Modifier + .padding(horizontal = 16.dp) + .clickable(onClick = onOpen), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + "STATUS WINDOW", + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + letterSpacing = 3.sp, + ) + Text( + "${state.rank} grade - Level ${state.level}", + color = c.text, + fontWeight = FontWeight.ExtraBold, + fontSize = IronLogType.section.fontSize.sp, + ) + Text( + "${state.activeTitle} • $totalSessions logged sessions", + color = c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + Text( + "OPEN", + color = c.accent, + fontWeight = FontWeight(IronLogType.button.fontWeight), + fontSize = IronLogType.meta.fontSize.sp, + ) + } + + LinearProgressIndicator( + progress = { xpFraction }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(999.dp)), + color = c.accent, + trackColor = c.accent.copy(alpha = 0.14f), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("${state.xpInLevel} / ${state.xpForNextLevel} XP", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + Text("${state.streakWeeks}w streak", color = c.accent, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight.Bold) + } + } + } +} + +@Composable +private fun FrequencyBars( + history: List, + barColor: androidx.compose.ui.graphics.Color, + trackColor: androidx.compose.ui.graphics.Color, + labelColor: androidx.compose.ui.graphics.Color, +) { + val today = LocalDate.now() + val counts = remember(history) { + val byDay = mutableMapOf() + history.forEach { entry -> + val day = parseHistoryInstant(entry.date)?.atZone(ZoneId.systemDefault())?.toLocalDate() + if (day != null) byDay[day] = (byDay[day] ?: 0) + 1 + } + (13 downTo 0).map { offset -> + val d = today.minusDays(offset.toLong()) + d to (byDay[d] ?: 0) + } + } + val maxCount = counts.maxOfOrNull { it.second }?.coerceAtLeast(1) ?: 1 + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + counts.forEach { (date, count) -> + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .background(trackColor, RoundedCornerShape(6.dp)), + contentAlignment = androidx.compose.ui.Alignment.BottomCenter, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height((56f * (count.toFloat() / maxCount)).dp) + .background(barColor, RoundedCornerShape(6.dp)), + ) + } + // FIXED: 25 — 2-char day labels: Mo, Tu, We, Th, Fr, Sa, Su + Text( + date.dayOfWeek.name.take(2).let { "${it[0].uppercaseChar()}${it[1].lowercaseChar()}" }, + color = labelColor, + fontSize = IronLogType.micro.fontSize.sp, + ) + } + } + } +} + +@Composable +private fun QuickNavButton( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + modifier: Modifier, + onClick: () -> Unit, +) { + val c = useTheme() + Column( + modifier + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(c.card, RoundedCornerShape(IronLogRadius.lg.dp)) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .clickable(onClick = onClick) + .padding(vertical = 12.dp, horizontal = 8.dp), + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon(icon, contentDescription = null, tint = c.accent) + Text(label, color = c.text, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight(IronLogType.button.fontWeight)) + } +} + +@Composable +private fun MiniAction(label: String, modifier: Modifier, onClick: () -> Unit) { + val c = useTheme() + Box( + modifier = modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.card, RoundedCornerShape(IronLogRadius.full.dp)) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + label, + color = c.text, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight(IronLogType.button.fontWeight), + textAlign = TextAlign.Center, + ) + } +} + +// FIXED: 24 — Remove shimmer animation; static accent-tinted pill with ✦ star +@Composable +private fun CoachMiniAction(modifier: Modifier, onClick: () -> Unit) { + val c = useTheme() + Box( + modifier = modifier + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(c.accent.copy(alpha = 0.12f), RoundedCornerShape(IronLogRadius.full.dp)) + .border(1.dp, c.accent.copy(alpha = 0.40f), RoundedCornerShape(IronLogRadius.full.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Text("✦", color = c.accent, fontSize = IronLogType.micro.fontSize.sp) + Text("COACH", color = c.accent, fontSize = IronLogType.meta.fontSize.sp, fontWeight = FontWeight(IronLogType.button.fontWeight)) + } + } +} + +/** Catmull-Rom spline → list of cubic Bezier segments for smooth chart curves. */ +private fun catmullRomPath(pts: List, tension: Float = 0.5f): Path { + val path = Path() + if (pts.isEmpty()) return path + path.moveTo(pts[0].x, pts[0].y) + if (pts.size == 1) return path + for (i in 0 until pts.size - 1) { + val p0 = pts.getOrElse(i - 1) { pts[i] } + val p1 = pts[i] + val p2 = pts[i + 1] + val p3 = pts.getOrElse(i + 2) { pts[i + 1] } + val cp1x = p1.x + (p2.x - p0.x) * tension / 3f + val cp1y = p1.y + (p2.y - p0.y) * tension / 3f + val cp2x = p2.x - (p3.x - p1.x) * tension / 3f + val cp2y = p2.y - (p3.y - p1.y) * tension / 3f + path.cubicTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y) + } + return path +} + +/** Catmull-Rom fill path: starts at baseline, traces the curve, closes back at baseline. */ +private fun catmullRomFillPath(pts: List, baselineY: Float, tension: Float = 0.5f): Path { + val path = Path() + if (pts.isEmpty()) return path + path.moveTo(pts[0].x, baselineY) + path.lineTo(pts[0].x, pts[0].y) + for (i in 0 until pts.size - 1) { + val p0 = pts.getOrElse(i - 1) { pts[i] } + val p1 = pts[i] + val p2 = pts[i + 1] + val p3 = pts.getOrElse(i + 2) { pts[i + 1] } + val cp1x = p1.x + (p2.x - p0.x) * tension / 3f + val cp1y = p1.y + (p2.y - p0.y) * tension / 3f + val cp2x = p2.x - (p3.x - p1.x) * tension / 3f + val cp2y = p2.y - (p3.y - p1.y) * tension / 3f + path.cubicTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y) + } + path.lineTo(pts.last().x, baselineY) + path.close() + return path +} + +@Composable +private fun SessionLineChart( + points: List, + lineColor: androidx.compose.ui.graphics.Color, + dotColor: androidx.compose.ui.graphics.Color, + gridColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Gray.copy(alpha = 0.25f), + yMax: Float? = null, + modifier: Modifier = Modifier, +) { + if (points.isEmpty()) return + val values = points.map { it.value.toFloat() } + val maxVal = (yMax ?: values.max()).coerceAtLeast(1f) + Canvas(modifier) { + val pad = 6f + val innerW = size.width - pad * 2 + val innerH = size.height - pad * 2 + val baselineY = pad + innerH + // 4 horizontal grid lines (matches 5 Y-axis tick labels: max, 75%, 50%, 25%, 0) + for (i in 0..4) { + val y = pad + innerH * i / 4f + drawLine( + color = gridColor, + start = Offset(pad, y), + end = Offset(pad + innerW, y), + strokeWidth = 1f, + ) + } + val pts = values.mapIndexed { i, v -> + val x = pad + if (values.size == 1) innerW / 2 else (i.toFloat() / (values.size - 1)) * innerW + val y = pad + innerH - (v / maxVal) * innerH + Offset(x, y) + } + if (pts.size > 1) { + // Gradient fill under the curve + drawPath( + catmullRomFillPath(pts, baselineY), + brush = Brush.verticalGradient( + colors = listOf(lineColor.copy(alpha = 0.28f), Color.Transparent), + startY = pad, + endY = baselineY, + ), + ) + // Smooth Catmull-Rom curve + drawPath(catmullRomPath(pts), color = lineColor, style = androidx.compose.ui.graphics.drawscope.Stroke(3f)) + } + pts.forEach { p -> + drawCircle(color = dotColor, radius = 4f, center = p) + } + } +} + +/** Round up to a clean axis tick (1, 2, 4, 5, 10, 20, …). */ +private fun niceAxisMax(value: Float): Float { + if (value <= 1f) return 1f + if (value <= 2f) return 2f + if (value <= 4f) return 4f + if (value <= 5f) return 5f + if (value <= 10f) return 10f + // For larger values, round up to next multiple of 5 or 10 + val mag = Math.pow(10.0, kotlin.math.floor(kotlin.math.log10(value.toDouble()))).toFloat() + val q = value / mag + val nice = when { + q <= 2f -> 2f + q <= 4f -> 4f + q <= 5f -> 5f + else -> 10f + } + return nice * mag +} + +private fun formatAxisValue(v: Float): String = when { + v >= 1000f -> "${(v / 1000f).let { if (it == it.toInt().toFloat()) it.toInt().toString() else String.format(java.util.Locale.US, "%.1f", it) }}k" + v == v.toInt().toFloat() -> v.toInt().toString() + else -> String.format(java.util.Locale.US, "%.1f", v) +} + +@Composable +private fun StatCard(label: String, value: String, modifier: Modifier = Modifier) { + val c = useTheme() + Card( + modifier, + shape = RoundedCornerShape(IronLogRadius.lg.dp), + colors = CardDefaults.cardColors(containerColor = c.surface), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + ) { + Column(Modifier.padding(12.dp)) { + Text( + label, + color = c.muted, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = FontWeight(IronLogType.meta.fontWeight), + ) + // FIXED: 23 — title (20sp) fits 4-col layout; display (32sp) was too large + Text( + value, + color = c.text, + fontWeight = FontWeight(IronLogType.display.fontWeight), + fontSize = IronLogType.title.fontSize.sp, + lineHeight = IronLogType.title.lineHeight.sp, + ) + } + } +} + +fun parseLocalDate(dateStr: String): LocalDate = LocalDate.parse(dateStr) +fun estimateOneRM(weight: Double, reps: Int = 5): Int = round(weight * (1 + kotlin.math.max(1, reps) / 30.0)).toInt() +fun statsGetStreak(history: List): Int = getStreak(history) + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/stats/VolumeAnalyticsScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/stats/VolumeAnalyticsScreen.kt new file mode 100644 index 0000000..d0ed95d --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/stats/VolumeAnalyticsScreen.kt @@ -0,0 +1,1190 @@ +package com.ironlog.app.ui.screens.stats + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.BarChart +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ironlog.app.domain.intelligence.FINE_MUSCLE_ABBREV +import com.ironlog.app.domain.intelligence.FINE_MUSCLE_DISPLAY +import com.ironlog.app.domain.intelligence.TrainingIntelligenceEngine +import com.ironlog.app.domain.intelligence.computeGranularVolume +import com.ironlog.app.domain.intelligence.foldGranularToRadar +import com.ironlog.app.services.ShareService +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.viewmodel.AppDataViewModel +import com.ironlog.app.ui.viewmodel.StatsViewModel +import java.time.Instant +import com.ironlog.app.domain.gamification.parseHistoryInstant +import java.time.LocalDate +import java.time.ZoneId +import java.time.temporal.WeekFields +import java.util.Locale +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.sin + +// ── Constants ───────────────────────────────────────────────────────────────── + +private enum class MuscleTab(val label: String, val days: Int) { + WEEK("WEEK", 7), DAYS7("7D", 7), DAYS30("30D", 30), PROGRAM("PROGRAM", 42) +} + +private val MUSCLE_AXES = listOf("Core", "Arms", "Chest", "Legs", "Back", "Shoulders") + +// ── Screen ──────────────────────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VolumeAnalyticsScreen( + vm: StatsViewModel = viewModel(), + appVm: AppDataViewModel = viewModel(), + onBack: () -> Unit = {}, + onOpenBodyWeight: () -> Unit = {}, +) { + val c = useTheme() + val context = LocalContext.current + val state by vm.state.collectAsState() + val appState by appVm.state.collectAsState() + + var activeTab by remember { mutableStateOf(MuscleTab.DAYS30) } + var drillMuscle by remember { mutableStateOf(null) } + val activePlan = appState.plans.firstOrNull { it.isActive } + val weightUnit = appState.settings.weightUnit + + // ── Filtering ────────────────────────────────────────────────────────── + val filtered = remember(state.history, activeTab) { + when (activeTab) { + MuscleTab.WEEK -> { + val startOfWeek = LocalDate.now().with( + WeekFields.ISO.dayOfWeek(), 1L + ) + state.history.filter { + val d = parseHistoryInstant(it.date)?.atZone(ZoneId.systemDefault())?.toLocalDate() ?: return@filter false + !d.isBefore(startOfWeek) + } + } + MuscleTab.DAYS7 -> state.history.filter { ageDays(it.date) <= 7 } + // GAP-04: PROGRAM uses active-plan block duration (weeks × days), falling back to 42 days + MuscleTab.PROGRAM -> { + val planDays = (activePlan?.days?.size ?: 0) + .let { if (it >= 3) it * 6 else 42 } // e.g. 4-day plan → 4×6=24 days ≈ 4 weeks + .coerceAtLeast(28) + state.history.filter { ageDays(it.date) <= planDays } + } + MuscleTab.DAYS30 -> state.history.filter { ageDays(it.date) <= 30 } + } + } + val rangeDays = activeTab.days + val previousFiltered = remember(state.history, rangeDays) { + state.history.filter { d -> ageDays(d.date) in (rangeDays + 1).toLong()..(rangeDays * 2L) } + } + + // ── Aggregations ─────────────────────────────────────────────────────── + val granularMuscles = remember(filtered) { computeGranularVolume(filtered) } + val volumeByMuscle = remember(granularMuscles) { foldGranularToRadar(granularMuscles, MUSCLE_AXES) } + // GAP-22: Previous period radar overlay + val previousGranularMuscles = remember(previousFiltered) { computeGranularVolume(previousFiltered) } + val previousVolumeByMuscle = remember(previousGranularMuscles) { foldGranularToRadar(previousGranularMuscles, MUSCLE_AXES) } + val weeklyVolume = remember(filtered) { computeWeeklyVolume(filtered) } + val snapshot = remember(filtered) { TrainingIntelligenceEngine.build(filtered, 0) } + + val currentTotalSets = volumeByMuscle.values.sum() + val weeksInRange = (rangeDays / 7f).coerceAtLeast(1f) + + val musclesHit = granularMuscles.size + val totalVolumeKg = filtered.sumOf { it.volume } + val actualWorkingSets = remember(filtered) { + filtered.sumOf { session -> session.exercises.sumOf { ex -> ex.sets.count { it.type != "warmup" } } } + } + val previousTotalKg = remember(previousFiltered) { previousFiltered.sumOf { it.volume } } + val deltaKg = totalVolumeKg - previousTotalKg + val deltaPctKg = if (previousTotalKg <= 0.0) null else (deltaKg * 100.0 / previousTotalKg) + val trendLabel = if (previousTotalKg <= 0.0) "Baseline" else when { + (deltaPctKg ?: 0.0) > 3.0 -> "Progressing" + (deltaPctKg ?: 0.0) < -3.0 -> "Regressing" + else -> "Stable" + } + + // Push/Pull/Legs balance from current tab's folded data + val pushSets = (volumeByMuscle["Chest"] ?: 0) + (volumeByMuscle["Shoulders"] ?: 0) + val pullSets = volumeByMuscle["Back"] ?: 0 + val legsSets = volumeByMuscle["Legs"] ?: 0 + val pplTotal = (pushSets + pullSets + legsSets).coerceAtLeast(1) + val pushPct = (pushSets * 100f / pplTotal).toInt() + val pullPct = (pullSets * 100f / pplTotal).toInt() + val legsPct = 100 - pushPct - pullPct + val pushWeekly = pushSets / weeksInRange + val pullWeekly = pullSets / weeksInRange + val legsWeekly = legsSets / weeksInRange + + // Next week actions + val actions = remember(pushPct, pullPct, legsPct, pushWeekly, pullWeekly, legsWeekly) { + buildNextWeekActions(pushPct, pullPct, legsPct, pushWeekly, pullWeekly, legsWeekly) + } + + // Session counts + val sessionCount = filtered.size + val prevSessionCount = previousFiltered.size + + // Share text + val shareText = buildString { + appendLine("IronLog Volume Analytics") + appendLine("Range: ${activeTab.label}") + appendLine("Total working sets: $currentTotalSets") + appendLine("Muscles hit: $musclesHit") + volumeByMuscle.forEach { (k, v) -> appendLine("$k: $v sets") } + appendLine("Push: ${String.format(Locale.US, "%.1f", pushWeekly)}/wk Pull: ${String.format(Locale.US, "%.1f", pullWeekly)}/wk Legs: ${String.format(Locale.US, "%.1f", legsWeekly)}/wk") + } + + LazyColumn( + modifier = Modifier.fillMaxSize().background(c.bg).statusBarsPadding(), + contentPadding = PaddingValues(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 100.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + + // ── 1. Header ────────────────────────────────────────────────────── + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + IconButton(onClick = onBack, modifier = Modifier.size(44.dp)) { + Icon( + Icons.AutoMirrored.Outlined.ArrowBack, "Back", + tint = c.accent, + modifier = Modifier.size(24.dp), + ) + } + Column { + Text( + "MUSCLE ANALYTICS", + color = c.text, + fontSize = IronLogType.section.fontSize.sp, + fontWeight = FontWeight(IronLogType.section.fontWeight), + letterSpacing = IronLogType.section.letterSpacing.sp, + ) + if (activePlan != null) { + Text( + "${activePlan.name} / ${if (activeTab == MuscleTab.PROGRAM) "program view" else "${activeTab.label} view"}", + color = c.accent, + fontSize = IronLogType.meta.fontSize.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + IconButton(onClick = { + ShareService.shareMinimalCardImage( + context = context, + title = "Ironlog Volume Analytics", + headline = activeTab.label, + metrics = listOf( + ShareService.ShareMetric("Working sets", currentTotalSets.toString()), + ShareService.ShareMetric("Muscles hit", musclesHit.toString()), + ShareService.ShareMetric("Trend", trendLabel), + ), + footnote = "Push $pushPct% · Pull $pullPct% · Legs $legsPct%", + ) + }) { + Icon(Icons.Outlined.Share, "Share", tint = c.muted) + } + } + } + + // ── 2. Tab switcher ──────────────────────────────────────────────── + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MuscleTab.entries.forEach { tab -> + val active = activeTab == tab + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(IronLogRadius.md.dp)) + .background(if (active) c.accent else c.surface) + .border(1.dp, if (active) c.accent else c.cardBorder, RoundedCornerShape(IronLogRadius.md.dp)) + .clickable { activeTab = tab } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + tab.label, + color = if (active) c.textOnAccent else c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight(IronLogType.button.fontWeight), + letterSpacing = IronLogType.button.letterSpacing.sp, + ) + } + } + } + } + + // ── 3. Summary card ──────────────────────────────────────────────── + item { + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + shape = RoundedCornerShape(IronLogRadius.lg.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + "IRONLOG", + color = c.text, + fontSize = IronLogType.title.fontSize.sp, + fontWeight = FontWeight(IronLogType.display.fontWeight), + letterSpacing = IronLogType.title.letterSpacing.sp, + ) + Text( + "VOLUME ANALYTICS", + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + if (activePlan != null) { + Text( + "${activePlan.name} / ${if (activeTab == MuscleTab.PROGRAM) "program view" else "${activeTab.label} view"}", + color = c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + if (filtered.isEmpty()) "Log training to unlock your volume interpretation." + else "${currentTotalSets} working sets logged across ${sessionCount} sessions.", + color = c.accent, + fontSize = IronLogType.body.fontSize.sp, + fontWeight = FontWeight(500), + ) + } + } + } + + // ── 4. Stats row ─────────────────────────────────────────────────── + item { + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + shape = RoundedCornerShape(IronLogRadius.lg.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + // GAP-07: convert to user's weight unit before display + val totalVolumeDisplay = if (weightUnit == "lbs") totalVolumeKg * 2.2046226218 else totalVolumeKg + val formattedVolume = if (totalVolumeDisplay >= 1000.0) + "${String.format(Locale.US, "%.1f", totalVolumeDisplay / 1000.0)}k$weightUnit" + else "${String.format(Locale.US, "%.0f", totalVolumeDisplay)}$weightUnit" + listOf( + sessionCount.toString() to "Sessions", + actualWorkingSets.toString() to "Sets", + musclesHit.toString() to "Muscles Hit", + formattedVolume to "Volume ($weightUnit)", + ).forEachIndexed { idx, (value, label) -> + if (idx > 0) Box(Modifier.width(1.dp).height(40.dp).background(c.faint)) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + value, + color = c.accent, + fontSize = IronLogType.title.fontSize.sp, + fontWeight = FontWeight(IronLogType.display.fontWeight), + ) + Text(label, color = c.muted, fontSize = IronLogType.micro.fontSize.sp) + } + } + } + } + } + + // ── 5. Performance Signals ───────────────────────────────────────── + item { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + "PERFORMANCE SIGNALS", + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Volume trend card + Card( + modifier = Modifier.weight(1f), + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + shape = RoundedCornerShape(IronLogRadius.lg.dp), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Volume trend", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + val trendColor = when (trendLabel) { + "Progressing" -> c.success + "Regressing" -> c.danger + else -> c.text + } + Text( + trendLabel, + color = trendColor, + fontSize = IronLogType.body.fontSize.sp, + fontWeight = FontWeight(700), + ) + Text( + if (deltaPctKg == null) "No prior data" + else "${if (deltaKg >= 0) "+" else ""}${String.format(Locale.US, "%.1f", deltaPctKg)}% vs prior", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } + // Consistency card + Card( + modifier = Modifier.weight(1f), + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + shape = RoundedCornerShape(IronLogRadius.lg.dp), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Workout consistency", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + Text( + if (sessionCount == 0) "Program baseline only" else "$sessionCount sessions", + color = c.text, + fontSize = IronLogType.body.fontSize.sp, + fontWeight = FontWeight(700), + ) + Text( + "$sessionCount vs $prevSessionCount sessions", + color = c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } + } + Text( + "Radar is summary only. Use bars and trend signals for precision.", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + OutlinedButton( + onClick = onOpenBodyWeight, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(IronLogRadius.md.dp), + ) { + Icon(Icons.Outlined.BarChart, null, tint = c.accent, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text("Open body composition stats", color = c.accent, fontSize = IronLogType.body.fontSize.sp) + } + } + } + + // ── 6. Volume Trend (Weeks) ──────────────────────────────────────── + item { + AnalyticsCard("VOLUME TREND (WEEKS)") { + WeeklyLineChart(weeklyVolume) + Text( + "Use this with imbalance insights to decide what to increase, hold, or reduce next week.", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } + + // ── 7. Muscle Volume Radar ───────────────────────────────────────── + item { + AnalyticsCard("MUSCLE VOLUME RADAR") { + RadarChart( + data = volumeByMuscle, + previousData = previousVolumeByMuscle.takeIf { it.values.any { v -> v > 0 } }, + ) + } + } + + // ── 8. Effective Sets Per Muscle ─────────────────────────────────── + item { + AnalyticsCard("EFFECTIVE SETS PER MUSCLE") { + EffectiveSetsChart(granularMuscles) + // Legend + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + listOf("0-9" to 0.35f, "10-20" to 0.6f, "21-25" to 0.85f, "25+" to 1f).forEach { (label, alpha) -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Box( + Modifier + .size(8.dp) + .clip(CircleShape) + .background(c.chartSecondary.copy(alpha = alpha)) + ) + Text(label, color = c.muted, fontSize = IronLogType.micro.fontSize.sp) + } + } + } + } + } + + // ── 9. Push / Pull / Legs Balance ───────────────────────────────── + item { + AnalyticsCard("PUSH / PULL / LEGS BALANCE") { + BalanceBar(pushPct, pullPct, legsPct) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + listOf( + Triple("Push", pushWeekly, c.chartPrimary), + Triple("Pull", pullWeekly, c.chartSecondary), + Triple("Legs", legsWeekly, c.accent), + ).forEach { (label, weekly, color) -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Box(Modifier.size(8.dp).clip(CircleShape).background(color)) + Text( + "$label ${String.format(Locale.US, "%.1f", weekly)}", + color = c.text, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight(600), + ) + val pct = when (label) { "Push" -> pushPct; "Pull" -> pullPct; else -> legsPct } + Text("$pct%", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + val balanceText = when { + abs(pushPct - pullPct) <= 18 -> "Balance is healthy. Keep volume distribution close to current." + pushPct > pullPct + 18 -> "Push is far ahead. Add more pulling movements." + pullPct > pushPct + 18 -> "Pull is far ahead. Add more pressing movements." + legsPct < 20 -> "Leg volume is low. Consider adding leg sessions." + else -> "Distribution looks reasonable. Monitor week to week." + } + Text(balanceText, color = c.subtext, fontSize = IronLogType.meta.fontSize.sp) + } + } + + // ── 9b. Imbalance Insights ──────────────────────────────────────── + if (granularMuscles.isNotEmpty()) { + item { + val chestTotal = (granularMuscles["upperChest"] ?: 0f) + + (granularMuscles["midChest"] ?: 0f) + + (granularMuscles["lowerChest"] ?: 0f) + val rearDeltSets = granularMuscles["rearDelts"] ?: 0f + val frontDeltSets = granularMuscles["frontDelts"] ?: 0f + val hamSets = granularMuscles["hamstrings"] ?: 0f + val quadSets = granularMuscles["quads"] ?: 0f + val insights = mutableListOf() + if (chestTotal > 0f && rearDeltSets < chestTotal * 0.35f) + insights += "Rear delts are low relative to chest — add face pulls or rear delt flyes." + if (quadSets > 0f && hamSets < quadSets * 0.55f) + insights += "Hamstrings volume is low relative to quads — add Romanian deadlifts or leg curls." + if (rearDeltSets > 0f && frontDeltSets > rearDeltSets * 1.6f) + insights += "Front delts far outpace rear delts — common with heavy pressing programs." + if (insights.isEmpty()) + insights += "Muscle balance looks good. No major imbalances detected." + AnalyticsCard("IMBALANCE INSIGHTS") { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + insights.forEach { tip -> + Text("• $tip", color = c.subtext, fontSize = IronLogType.body.fontSize.sp) + } + } + } + } + } + + // ── 10. Next Week Actions ────────────────────────────────────────── + item { + AnalyticsCard("NEXT WEEK ACTIONS") { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + actions.forEachIndexed { i, action -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + modifier = Modifier + .size(22.dp) + .clip(CircleShape) + .background(c.accent.copy(alpha = 0.15f)) + .border(1.dp, c.accentBorder, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + "${i + 1}", + color = c.accent, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = FontWeight(700), + ) + } + Card( + modifier = Modifier.weight(1f), + colors = CardDefaults.cardColors(containerColor = c.surface), + shape = RoundedCornerShape(IronLogRadius.md.dp), + ) { + Text( + action, + color = c.text, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier.padding(10.dp), + ) + } + } + } + } + } + } + + // ── 11. Muscle Breakdown header ──────────────────────────────────── + item { + Text( + "MUSCLE BREAKDOWN", + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + } + + // ── 11. Muscle Breakdown rows ────────────────────────────────────── + if (granularMuscles.isEmpty()) { + item { + Text("Log workouts to see muscle breakdown.", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + } + } else { + val maxVal = granularMuscles.values.maxOrNull()?.coerceAtLeast(0.1f) ?: 1f + val cardShape = RoundedCornerShape(IronLogRadius.lg.dp) + item { + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + shape = cardShape, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + granularMuscles.entries.toList().forEach { (muscle, setsF) -> + val ratio = setsF / maxVal + val dotColor = when { + setsF >= 25f -> c.chartSecondary + setsF >= 21f -> c.chartSecondary.copy(alpha = 0.85f) + setsF >= 10f -> c.chartSecondary.copy(alpha = 0.6f) + else -> c.chartSecondary.copy(alpha = 0.4f) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { drillMuscle = muscle } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(Modifier.size(8.dp).clip(CircleShape).background(dotColor)) + Text( + FINE_MUSCLE_DISPLAY[muscle] ?: muscle, + color = c.text, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier.width(110.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Box( + modifier = Modifier.weight(1f).height(8.dp).clip(RoundedCornerShape(99.dp)).background(c.faint), + ) { + Box( + Modifier + .fillMaxWidth(ratio) + .fillMaxHeight() + .clip(RoundedCornerShape(99.dp)) + .background(c.chartPrimary), + ) + } + Text( + String.format(Locale.US, "%.1f", setsF), + color = c.accent, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight(700), + modifier = Modifier.width(32.dp), + ) + } + } + } + } + } + } + + item { Spacer(Modifier.height(24.dp)) } + } + + // ── Muscle Drill-Down Sheet ──────────────────────────────────────────── + if (drillMuscle != null) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val targetMuscle = drillMuscle!! + val displayName = FINE_MUSCLE_DISPLAY[targetMuscle] ?: targetMuscle + + // Compute exercises targeting this muscle, aggregated over current window + val drillExercises = remember(filtered, targetMuscle) { + val accumulator = mutableMapOf>() // exerciseId -> (name, sets, volume) + filtered.forEach { session -> + session.exercises.forEach { ex -> + val targets = when { + ex.primaryMuscles.isNotEmpty() -> ex.primaryMuscles + ex.primaryMuscle != null -> listOf(ex.primaryMuscle) + else -> emptyList() + } + // Match by direct muscle name (camelCase key) or by lowercased display name + val displayLower = FINE_MUSCLE_DISPLAY[targetMuscle]?.lowercase() ?: targetMuscle.lowercase() + val matches = targets.any { m -> + m.equals(targetMuscle, ignoreCase = true) || + m.replace(" ", "").equals(targetMuscle, ignoreCase = true) || + m.lowercase() == displayLower + } + if (matches) { + val workingSets = ex.sets.count { it.type != "warmup" } + val vol = ex.sets.filter { it.type != "warmup" }.sumOf { it.weight * it.reps } + val existing = accumulator[ex.exerciseId.ifBlank { ex.name }] + if (existing != null) { + accumulator[ex.exerciseId.ifBlank { ex.name }] = Triple( + existing.first, + existing.second + workingSets, + existing.third + vol, + ) + } else { + accumulator[ex.exerciseId.ifBlank { ex.name }] = Triple(ex.name, workingSets, vol) + } + } + } + } + accumulator.values.sortedByDescending { it.second } + } + + ModalBottomSheet( + onDismissRequest = { drillMuscle = null }, + sheetState = sheetState, + containerColor = c.card, + contentColor = c.text, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + displayName.uppercase(), + color = c.text, + fontSize = IronLogType.section.fontSize.sp, + fontWeight = FontWeight.Bold, + letterSpacing = IronLogType.section.letterSpacing.sp, + ) + Text( + "Exercises targeting this muscle in the current window", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + if (drillExercises.isEmpty()) { + Text( + "No exercises found for $displayName in the current period.", + color = c.subtext, + fontSize = IronLogType.body.fontSize.sp, + ) + } else { + // Column header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Exercise", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier.weight(1f), + ) + Text( + "Sets", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier.width(36.dp), + fontWeight = FontWeight(600), + ) + Text( + "Volume", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier.width(72.dp), + fontWeight = FontWeight(600), + ) + } + Box(Modifier.fillMaxWidth().height(1.dp).background(c.cardBorder)) + drillExercises.forEach { (name, sets, volume) -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + name, + color = c.text, + fontSize = IronLogType.body.fontSize.sp, + modifier = Modifier.weight(1f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + sets.toString(), + color = c.accent, + fontSize = IronLogType.body.fontSize.sp, + fontWeight = FontWeight(700), + modifier = Modifier.width(36.dp), + ) + val formattedVol = if (volume >= 1000.0) + "${String.format(Locale.US, "%.1f", volume / 1000.0)}k" + else + String.format(Locale.US, "%.0f", volume) + Text( + formattedVol, + color = c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier.width(72.dp), + ) + } + } + } + } + } + } +} + +// ── Card wrapper ────────────────────────────────────────────────────────────── + +@Composable +private fun AnalyticsCard(title: String, content: @Composable () -> Unit) { + val c = useTheme() + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + shape = RoundedCornerShape(IronLogRadius.lg.dp), + ) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + title, + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + content() + } + } +} + +// ── Effective Sets Per Muscle (horizontally scrollable vertical bar chart) ──── + +@Composable +private fun EffectiveSetsChart(muscles: Map) { + val c = useTheme() + if (muscles.isEmpty()) { + Box(Modifier.fillMaxWidth().height(120.dp), contentAlignment = Alignment.Center) { + Text("Log workouts to see muscle chart", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + } + return + } + + // Raw totals for the selected window, sorted descending + val sortedData = muscles.entries.sortedByDescending { it.value } + + val maxSets = sortedData.firstOrNull()?.value ?: 0f + val yMax = listOf(10f, 20f, 30f, 50f, 75f, 100f, 150f).firstOrNull { it >= maxSets } ?: 150f + val yTicks = listOf(0f, yMax * 0.25f, yMax * 0.5f, yMax * 0.75f, yMax) + + fun toArgb(col: androidx.compose.ui.graphics.Color) = android.graphics.Color.argb( + (col.alpha * 255).toInt(), (col.red * 255).toInt(), (col.green * 255).toInt(), (col.blue * 255).toInt() + ) + val valuePaint = remember(c) { + android.graphics.Paint().apply { + textSize = 26f; textAlign = android.graphics.Paint.Align.CENTER; isAntiAlias = true + typeface = android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD) + } + }.also { it.color = toArgb(c.text) } + val labelPaint = remember(c) { + android.graphics.Paint().apply { + textSize = 22f; textAlign = android.graphics.Paint.Align.CENTER; isAntiAlias = true + } + }.also { it.color = toArgb(c.muted) } + + val barW = 44.dp + val chartH = 160.dp + + Row(modifier = Modifier.fillMaxWidth().height(chartH), verticalAlignment = Alignment.Top) { + // Y-axis + Column( + modifier = Modifier.width(28.dp).height(chartH), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.End, + ) { + yTicks.reversed().forEach { v -> + Text( + if (v == v.toInt().toFloat()) v.toInt().toString() else String.format(Locale.US, "%.0f", v), + color = c.muted, + fontSize = IronLogType.micro.fontSize.sp, + ) + } + } + Spacer(Modifier.width(4.dp)) + // Scrollable bars + Row( + modifier = Modifier.weight(1f).height(chartH).horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + sortedData.forEach { (muscle, rawVal) -> + val ratio = (rawVal / yMax).coerceIn(0f, 1f) + val barColor = when { + rawVal >= 25f -> c.chartSecondary + rawVal >= 21f -> c.chartSecondary.copy(alpha = 0.85f) + rawVal >= 10f -> c.chartSecondary.copy(alpha = 0.6f) + else -> c.chartSecondary.copy(alpha = 0.4f) + } + Canvas( + modifier = Modifier.width(barW).height(chartH), + ) { + val topPad = 28f + val botPad = 32f + val plotH = size.height - topPad - botPad + val bH = plotH * ratio + val bL = (size.width - barW.toPx() * 0.8f) / 2f + val bR = size.width - bL + val bW = bR - bL + val top = topPad + plotH - bH + drawRoundRect( + color = barColor, + topLeft = Offset(bL, top), + size = Size(bW, bH.coerceAtLeast(2f)), + cornerRadius = CornerRadius(5f, 5f), + ) + val nc = drawContext.canvas.nativeCanvas + nc.drawText( + String.format(Locale.US, "%.1f", rawVal), + size.width / 2f, top - 6f, valuePaint, + ) + nc.drawText( + FINE_MUSCLE_ABBREV[muscle] ?: muscle.take(7), + size.width / 2f, size.height - 4f, labelPaint, + ) + } + } + } + } +} + +// ── Push/Pull/Legs Balance Bar ──────────────────────────────────────────────── + +@Composable +private fun BalanceBar(pushPct: Int, pullPct: Int, legsPct: Int) { + val c = useTheme() + Box( + modifier = Modifier.fillMaxWidth().height(10.dp).clip(RoundedCornerShape(99.dp)).background(c.faint), + ) { + Row(modifier = Modifier.fillMaxSize()) { + Box(Modifier.weight(pushPct.coerceAtLeast(1).toFloat()).fillMaxSize().background(c.chartPrimary)) + Box(Modifier.weight(pullPct.coerceAtLeast(1).toFloat()).fillMaxSize().background(c.chartSecondary)) + Box(Modifier.weight(legsPct.coerceAtLeast(1).toFloat()).fillMaxSize().background(c.accent)) + } + } +} + +// ── Radar Chart ─────────────────────────────────────────────────────────────── + +@Composable +private fun RadarChart(data: Map, previousData: Map? = null) { + val c = useTheme() + val hasData = data.values.any { it > 0 } + val maxVal = (((data.values.maxOrNull() ?: 0) + (previousData?.values?.maxOrNull() ?: 0)).coerceAtLeast(1)) + + if (!hasData) { + Box(Modifier.fillMaxWidth().height(220.dp), contentAlignment = Alignment.Center) { + Text("Log workouts to see muscle radar", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + } + return + } + + fun toArgb(col: androidx.compose.ui.graphics.Color) = android.graphics.Color.argb( + 255, (col.red * 255).toInt(), (col.green * 255).toInt(), (col.blue * 255).toInt() + ) + val labelPaint = remember(c) { + android.graphics.Paint().apply { + textSize = 34f; textAlign = android.graphics.Paint.Align.CENTER; isAntiAlias = true + typeface = android.graphics.Typeface.create(android.graphics.Typeface.DEFAULT, android.graphics.Typeface.BOLD) + } + }.also { it.color = toArgb(c.text) } + val valuePaint = remember(c) { + android.graphics.Paint().apply { + textSize = 28f; textAlign = android.graphics.Paint.Align.CENTER; isAntiAlias = true + } + }.also { it.color = toArgb(c.muted) } + + Canvas(Modifier.fillMaxWidth().height(280.dp)) { + val center = Offset(size.width / 2f, size.height / 2f) + val radius = size.minDimension * 0.32f + val labelR = radius + 44f + val n = MUSCLE_AXES.size + + repeat(4) { ring -> + val rr = radius * ((ring + 1) / 4f) + val pts = (0 until n).map { i -> + val a = (2 * PI * i / n - PI / 2).toFloat() + Offset(center.x + rr * cos(a), center.y + rr * sin(a)) + } + for (i in pts.indices) drawLine(c.faint, pts[i], pts[(i + 1) % n], 1f) + } + repeat(n) { i -> + val a = (2 * PI * i / n - PI / 2).toFloat() + drawLine(c.faint, center, Offset(center.x + radius * cos(a), center.y + radius * sin(a)), 1f) + } + + // GAP-22: Previous period polygon (drawn first so current overlays it) + if (previousData != null) { + val prevPath = Path() + MUSCLE_AXES.forEachIndexed { i, key -> + val ratio = (previousData[key] ?: 0).toFloat() / maxVal + val a = (2 * PI * i / n - PI / 2).toFloat() + val p = Offset(center.x + radius * ratio * cos(a), center.y + radius * ratio * sin(a)) + if (i == 0) prevPath.moveTo(p.x, p.y) else prevPath.lineTo(p.x, p.y) + } + prevPath.close() + drawPath(prevPath, c.muted.copy(alpha = 0.15f)) + drawPath(prevPath, c.muted.copy(alpha = 0.6f), style = Stroke( + width = 2f, + pathEffect = androidx.compose.ui.graphics.PathEffect.dashPathEffect(floatArrayOf(12f, 6f), 0f), + )) + } + + val path = Path() + MUSCLE_AXES.forEachIndexed { i, key -> + val ratio = (data[key] ?: 0).toFloat() / maxVal + val a = (2 * PI * i / n - PI / 2).toFloat() + val p = Offset(center.x + radius * ratio * cos(a), center.y + radius * ratio * sin(a)) + if (i == 0) path.moveTo(p.x, p.y) else path.lineTo(p.x, p.y) + } + path.close() + drawPath(path, c.chartPrimary.copy(alpha = 0.35f)) + drawPath(path, c.chartPrimary, style = Stroke(2.5f)) + + MUSCLE_AXES.forEachIndexed { i, key -> + val ratio = (data[key] ?: 0).toFloat() / maxVal + val a = (2 * PI * i / n - PI / 2).toFloat() + val p = Offset(center.x + radius * ratio * cos(a), center.y + radius * ratio * sin(a)) + drawCircle(c.accent, 5f, p) + } + + val nc = drawContext.canvas.nativeCanvas + MUSCLE_AXES.forEachIndexed { i, label -> + val a = (2 * PI * i / n - PI / 2).toFloat() + val lx = center.x + labelR * cos(a) + val ly = center.y + labelR * sin(a) + labelPaint.textSize / 3f + nc.drawText(label, lx, ly, labelPaint) + nc.drawText((data[label] ?: 0).toString(), lx, ly + valuePaint.textSize + 4f, valuePaint) + } + } + + // GAP-22: Legend row (only shown when previous period data is available) + if (previousData != null) { + Spacer(Modifier.height(6.dp)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + // Current period swatch + Box(Modifier.size(width = 20.dp, height = 3.dp).background(c.chartPrimary)) + Spacer(Modifier.width(4.dp)) + Text("Current", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + Spacer(Modifier.width(16.dp)) + // Previous period dashed swatch (simulated with two boxes) + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + repeat(3) { + Box(Modifier.size(width = 5.dp, height = 2.dp).background(c.muted.copy(alpha = 0.6f))) + } + } + Spacer(Modifier.width(4.dp)) + Text("Previous period", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + } +} + +// ── Weekly Line Chart ───────────────────────────────────────────────────────── + +@Composable +private fun WeeklyLineChart(points: List) { + val c = useTheme() + if (points.isEmpty()) { + Text("No weekly data yet", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + return + } + val maxV = points.maxOrNull()?.coerceAtLeast(1) ?: 1 + val yMax = niceVolumeAxisMax(maxV.toFloat()) + + Row(modifier = Modifier.fillMaxWidth().height(160.dp), verticalAlignment = Alignment.Top) { + Column( + modifier = Modifier.width(44.dp).height(160.dp), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.End, + ) { + listOf(yMax, yMax * 0.75f, yMax * 0.5f, yMax * 0.25f, 0f).forEach { v -> + Text(formatVolumeAxisValue(v), color = c.muted, fontSize = IronLogType.micro.fontSize.sp) + } + } + Spacer(Modifier.width(6.dp)) + Canvas(Modifier.weight(1f).height(160.dp)) { + val pad = 6f + val w = size.width - pad * 2 + val h = size.height - pad * 2 + for (i in 0..4) { + val y = pad + h * i / 4f + val dashW = w / 48f + for (d in 0 until 24) { + val sx = pad + d * dashW * 2 + drawLine(c.faint, Offset(sx, y), Offset(sx + dashW, y), 1f) + } + } + val offsets = points.mapIndexed { i, v -> + val x = pad + if (points.size == 1) w / 2 else i.toFloat() / (points.size - 1) * w + val y = pad + h - (v / yMax) * h + Offset(x, y) + } + if (offsets.size > 1) drawPath(catmullRomPath(offsets), c.chartPrimary, style = Stroke(3f)) + offsets.forEach { drawCircle(c.accent, 3.5f, it) } + } + } +} + +// ── Private helpers ─────────────────────────────────────────────────────────── + +private fun niceVolumeAxisMax(value: Float): Float { + if (value <= 0f) return 10f + val mag = Math.pow(10.0, kotlin.math.floor(kotlin.math.log10(value.toDouble()))).toFloat() + val q = value / mag + val nice = when { q <= 1f -> 1f; q <= 2f -> 2f; q <= 4f -> 4f; q <= 5f -> 5f; else -> 10f } + return nice * mag +} + +private fun formatVolumeAxisValue(v: Float): String = when { + v >= 1000f -> "${(v / 1000f).toInt()}k" + v == v.toInt().toFloat() -> v.toInt().toString() + else -> String.format(Locale.US, "%.1f", v) +} + +private fun catmullRomPath(pts: List): Path { + val p = Path() + if (pts.isEmpty()) return p + p.moveTo(pts[0].x, pts[0].y) + if (pts.size == 1) return p + for (i in 0 until pts.size - 1) { + val p0 = pts.getOrNull(i - 1) ?: pts[i] + val p1 = pts[i]; val p2 = pts[i + 1]; val p3 = pts.getOrNull(i + 2) ?: p2 + val c1 = Offset(p1.x + (p2.x - p0.x) / 6f, p1.y + (p2.y - p0.y) / 6f) + val c2 = Offset(p2.x - (p3.x - p1.x) / 6f, p2.y - (p3.y - p1.y) / 6f) + p.cubicTo(c1.x, c1.y, c2.x, c2.y, p2.x, p2.y) + } + return p +} + +private fun ageDays(iso: String): Long { + val t = parseHistoryInstant(iso)?.atZone(ZoneId.systemDefault())?.toLocalDate() ?: return Long.MAX_VALUE + return java.time.temporal.ChronoUnit.DAYS.between(t, LocalDate.now()) +} + +private fun computeWeeklyVolume(history: List): List { + val byWeek = linkedMapOf() + history.forEach { h -> + val d = parseHistoryInstant(h.date)?.atZone(ZoneId.systemDefault())?.toLocalDate() ?: return@forEach + val wf = WeekFields.ISO + val key = "${d.get(wf.weekBasedYear())}-${d.get(wf.weekOfWeekBasedYear()).toString().padStart(2, '0')}" + byWeek[key] = (byWeek[key] ?: 0) + h.exercises.sumOf { ex -> ex.sets.count { s -> s.type != "warmup" } } + } + return byWeek.entries.sortedBy { it.key }.takeLast(12).map { it.value } +} + +private fun buildNextWeekActions( + pushPct: Int, pullPct: Int, legsPct: Int, + pushW: Float, pullW: Float, legsW: Float, +): List { + val list = mutableListOf() + val diff = abs(pushPct - pullPct) + when { + diff <= 18 -> list.add("Balance is healthy. Keep volume distribution close to current.") + pushPct > pullPct + 18 -> list.add("Push is far ahead. Add more pulling movements (rows, pull-ups).") + else -> list.add("Pull is far ahead. Add more pressing movements (bench, overhead).") + } + if (legsPct < 20) list.add("Leg volume is low ($legsPct%). Consider adding leg sessions next week.") + if (pushW < 10f) list.add("Push volume (${String.format(Locale.US, "%.1f", pushW)} sets/wk) is below MEV of 10. Aim higher.") + if (pullW < 10f) list.add("Pull volume (${String.format(Locale.US, "%.1f", pullW)} sets/wk) is below MEV of 10. Aim higher.") + if (legsW < 12f) list.add("Leg volume (${String.format(Locale.US, "%.1f", legsW)} sets/wk) is below MEV of 12. Add leg work.") + if (list.size == 1 && diff <= 5 && pushW >= 10f && pullW >= 10f && legsW >= 12f) { + list.add("All muscle groups are in productive ranges. Consider progressive overload.") + } + return list +} + + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/workout/ActiveWorkoutScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/workout/ActiveWorkoutScreen.kt new file mode 100644 index 0000000..6956d9b --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/workout/ActiveWorkoutScreen.kt @@ -0,0 +1,3250 @@ +package com.ironlog.app.ui.screens.workout + +import android.app.Application +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.AddCircleOutline +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.EmojiEvents +import androidx.compose.material.icons.outlined.DragHandle +import androidx.compose.material.icons.outlined.NoteAlt +import androidx.compose.material.icons.outlined.Timer +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.ironlog.app.data.model.LegacyExerciseShape +import com.ironlog.app.data.model.SetInput +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.data.objectbox.WorkoutEntity +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity +import com.ironlog.app.data.objectbox.WorkoutEntity_ +import com.ironlog.app.data.repository.ExerciseRepository +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.data.repository.WorkoutRepository +import com.ironlog.app.domain.intelligence.CloudAiEngine +import com.ironlog.app.domain.intelligence.CloudAiKeyStore +import com.ironlog.app.domain.intelligence.TrainingIntelligenceEngine +import com.ironlog.app.ui.viewmodel.AppDataViewModel +import com.valentinilk.shimmer.shimmer +import com.ironlog.app.domain.milestone.MilestoneService +import com.ironlog.app.services.WorkoutForegroundService +import com.ironlog.app.services.WorkoutNotificationBridge +import com.ironlog.app.services.ShareService +import com.ironlog.app.ui.components.SetRow +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.model.UiPlan +import com.ironlog.app.ui.model.UiPlanDay +import com.ironlog.app.ui.model.UiPlanExercise +import timber.log.Timber +import com.ironlog.app.ui.state.AddedExerciseEntry +import com.ironlog.app.ui.state.LoggedSet +import com.ironlog.app.ui.state.WorkoutAction +import com.ironlog.app.ui.state.WorkoutState +import com.ironlog.app.ui.state.workoutReducer +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.ui.theme.IronLogThemeTokens +import com.ironlog.app.ui.viewmodel.PlansViewModel +import com.ironlog.app.util.formatDurationShort +import com.ironlog.app.util.formatWeightFromKg +import com.ironlog.app.util.HapticsEngine +import com.ironlog.app.util.calculatePlates +import com.ironlog.app.ui.screens.settings.GymProfileDto +import com.ironlog.app.ui.screens.settings.DEFAULT_PLATES +import com.ironlog.app.ui.screens.settings.PlateDto +import com.ironlog.app.ui.screens.stats.estimateOneRM +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import kotlin.math.round +import kotlin.math.roundToInt +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds +import io.github.vinceglb.confettikit.compose.ConfettiKit +import io.github.vinceglb.confettikit.core.Party +import io.github.vinceglb.confettikit.core.Position +import io.github.vinceglb.confettikit.core.Rotation +import io.github.vinceglb.confettikit.core.emitter.Emitter +import io.github.vinceglb.confettikit.core.models.Shape +import io.github.vinceglb.confettikit.core.models.Size + +private const val ACTIVE_WORKOUT_SESSION_PREFIX = "@ironlog/activeWorkoutSession/" +private const val COMPARISON_USAGE_KEY = "comparison_usage_v1" +private val RATE_THRESHOLDS = listOf(10, 25, 50) +private val FUN_COMPARISONS = listOf( + FunComparison(0, "a house cat", "🐈"), + FunComparison(500, "a baby goat", "🐐"), + FunComparison(1000, "a large pumpkin", "🎃"), + FunComparison(2000, "a baby elephant", "🐘"), + FunComparison(3500, "a baby hippo", "🦛"), + FunComparison(5000, "a grand piano", "🎹"), + FunComparison(7500, "a polar bear", "🐻‍❄️"), + FunComparison(10000, "a small car", "🚗"), + FunComparison(15000, "a T-Rex", "🦖"), + FunComparison(20000, "a rhino", "🦏"), + FunComparison(25000, "an orca whale", "🐋"), + FunComparison(35000, "an elephant", "🐘"), + FunComparison(40000, "a school bus", "🚌"), + FunComparison(60000, "a space shuttle", "🚀"), + FunComparison(100000, "a blue whale", "🐋"), +) + +data class FunComparison(val threshold: Int, val text: String, val icon: String) +data class WorkoutCompletionCelebration( + val hasPrCelebration: Boolean = false, + val hasStreak30Celebration: Boolean = false, +) + +data class NormalizedSessionExercise( + val name: String, + val exerciseId: String, + val sets: Int, + val reps: Int, + val trackingType: String, + val isWarmup: Boolean, + val equipment: String? = null, +) + +class ActiveWorkoutViewModel(application: Application) : AndroidViewModel(application) { + private val workoutRepo = WorkoutRepository() + private val settingsRepo = SettingsRepository() + private val milestoneService = MilestoneService() + // Read weight unit once from settings so the PR banner uses the correct unit. + private var vmWeightUnit: String = "kg" + private val _workoutState = MutableStateFlow(WorkoutState()) + val workoutState: StateFlow = _workoutState.asStateFlow() + + private val _elapsedSeconds = MutableStateFlow(0) + val elapsedSeconds: StateFlow = _elapsedSeconds.asStateFlow() + + // Timer only starts when the first set is logged — until then the display shows "--:--". + private val _timerStarted = MutableStateFlow(false) + val timerStarted: StateFlow = _timerStarted.asStateFlow() + + private val _prBanner = MutableStateFlow(null) + val prBanner: StateFlow = _prBanner.asStateFlow() + + private val _showCompletionSheet = MutableStateFlow(false) + val showCompletionSheet: StateFlow = _showCompletionSheet.asStateFlow() + private val _activeWorkoutIdSignal = MutableStateFlow(null) + val activeWorkoutIdSignal: StateFlow = _activeWorkoutIdSignal.asStateFlow() + + // ObjectBox workout tracking + private var activeWorkoutId: String? = null + private var timerStartEpochMs: Long? = null + // UI rows can repeat the same exercise id, so persistence must bind by row index. + private var workoutExerciseIdByIndex: Map = emptyMap() + // Historical best 1RM per exerciseId + private var historicalBest1rm: MutableMap = mutableMapOf() + private var hadPrThisSession: Boolean = false + private val json = Json { ignoreUnknownKeys = true } + // Ordered indices from the composable — persisted in draft so minimize/resume restores order. + private var persistedOrderedIndices: List = emptyList() + private val _restoredOrderedIndices = MutableStateFlow>(emptyList()) + val restoredOrderedIndices: StateFlow> = _restoredOrderedIndices.asStateFlow() + + fun updateOrderedIndices(indices: List) { + persistedOrderedIndices = indices + } + + init { + // Load weight unit so the PR banner displays in the user's chosen unit. + viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + val raw = settingsRepo.getString("ironlog_settings") + val json = runCatching { org.json.JSONObject(raw ?: "{}") }.getOrDefault(org.json.JSONObject()) + vmWeightUnit = json.optString("weightUnit", "kg").ifBlank { "kg" } + } + // Tick elapsed timer from a stable epoch so minimize/background/screen-off + // never drifts or resets the counter. + viewModelScope.launch { + while (true) { + delay(1000) + val start = timerStartEpochMs + if (_timerStarted.value && start != null) { + _elapsedSeconds.value = ((System.currentTimeMillis() - start) / 1000L).coerceAtLeast(0L).toInt() + } + } + } + } + + /** + * Called when the very first set of a session is logged. + * Idempotent — safe to call multiple times (noop after first call). + */ + private fun startTimerOnFirstSet() { + if (_timerStarted.value) return + _timerStarted.value = true + val nowMs = System.currentTimeMillis() + timerStartEpochMs = nowMs + _elapsedSeconds.value = ((System.currentTimeMillis() - nowMs) / 1000L).coerceAtLeast(0L).toInt() + viewModelScope.launch { + settingsRepo.setString("active_workout_start_ms", nowMs.toString()) + WorkoutForegroundService.start( + getApplication(), + activeWorkoutName ?: "Workout", + nowMs, + ) + } + } + + /** The name shown in the foreground-service notification. Derived lazily once [initWorkout] runs. */ + private var activeWorkoutName: String? = null + + fun initWorkout(dayId: String) { + if (activeWorkoutId != null) return + viewModelScope.launch { + // Resume existing active workout first (survives process/background recreation). + val persistedActiveId = runCatching { settingsRepo.getActiveWorkoutId() }.getOrNull() + if (!persistedActiveId.isNullOrBlank()) { + val resumed = runCatching { workoutRepo.getWorkoutDetailSnapshot(persistedActiveId) }.getOrNull() + if (resumed != null) { + activeWorkoutId = resumed.workout.uid + activeWorkoutName = resumed.workout.name.ifBlank { "Workout in progress" } + _activeWorkoutIdSignal.value = resumed.workout.uid + bindWorkoutExerciseRows(resumed.exercises) + val persistedStart = settingsRepo.getString("active_workout_start_ms")?.toLongOrNull() + if (persistedStart != null && persistedStart > 0L) { + timerStartEpochMs = persistedStart + _timerStarted.value = true + _elapsedSeconds.value = ((System.currentTimeMillis() - persistedStart) / 1000L).coerceAtLeast(0L).toInt() + } + if (persistedStart != null && persistedStart > 0L) { + WorkoutForegroundService.start( + getApplication(), + activeWorkoutName ?: "Workout", + persistedStart, + ) + } + restoreDraftIfAny() + return@launch + } + } + if (dayId.isBlank()) { + // Re-open path without a day id should resume only. + // If there is no persisted active workout, avoid creating a new one implicitly. + return@launch + } + try { + val workout = workoutRepo.startWorkoutFromPlanDay(dayId) + activeWorkoutId = workout.uid + activeWorkoutName = workout.name.ifBlank { "Workout in progress" } + _activeWorkoutIdSignal.value = workout.uid + // Note: foreground service shows the live notification (with rest timer inline). + // Do NOT call WorkoutNotificationBridge.showActiveWorkout — it creates a duplicate loud notification. + val detail = workoutRepo.getWorkoutDetailSnapshot(workout.uid) + bindWorkoutExerciseRows(detail.exercises) + persistDraft(_workoutState.value) + restoreDraftIfAny() + } catch (_: Exception) { + // If day not in ObjectBox yet, start empty workout + try { + val workout = workoutRepo.startEmptyWorkout("Quick Workout") + activeWorkoutId = workout.uid + activeWorkoutName = workout.name + _activeWorkoutIdSignal.value = workout.uid + // Foreground service handles the live notification — no duplicate from WorkoutNotificationBridge. + persistDraft(_workoutState.value) + restoreDraftIfAny() + } catch (_: Exception) { /* silent — UI still works in-memory */ } + } + } + } + + /** Loads last-session ghost data for a list of exercise IDs, dispatching UpdateGhost actions. */ + fun loadGhostData(exerciseIds: List) { + if (exerciseIds.isEmpty()) return + viewModelScope.launch { + exerciseIds.forEachIndexed { idx, exerciseId -> + runCatching { + val sets = workoutRepo.getLastSessionSetsForExercise(exerciseId) + if (sets.isNotEmpty()) { + val ghost = com.ironlog.app.ui.state.GhostData( + sets = sets.map { s -> + com.ironlog.app.ui.state.GhostSet( + weight = s.weight, + reps = s.reps, + rpe = s.rpe, + ) + }, + ) + dispatch(WorkoutAction.UpdateGhost(idx, ghost)) + } + } + } + } + } + + fun dispatch(action: WorkoutAction) { + // Clean up DB row when an exercise is removed mid-workout, then re-index the binding map. + if (action is WorkoutAction.RemoveExercise && activeWorkoutId != null) { + val uid = workoutExerciseIdByIndex[action.exIndex] + if (!uid.isNullOrBlank()) { + viewModelScope.launch { + runCatching { workoutRepo.deleteWorkoutExercise(uid) } + } + } + // Re-index: remove the entry for exIndex and shift all higher indices down by 1. + workoutExerciseIdByIndex = workoutExerciseIdByIndex + .filterKeys { it != action.exIndex } + .mapKeys { (k, _) -> if (k > action.exIndex) k - 1 else k } + } + val next = workoutReducer(_workoutState.value, action) + _workoutState.value = next + // Persist immediately so minimize/background cannot drop the latest typed input. + if (activeWorkoutId != null) persistDraft(next) + } + + fun logSet(exIndex: Int, exerciseId: String, weightText: String, repsText: String, trackingType: String, restSeconds: Int) { + val weight = weightText.toDoubleOrNull() ?: 0.0 + val reps = repsText.toDoubleOrNull() ?: 0.0 + if (weight <= 0.0 && reps <= 0.0) return + + // Start the workout timer the first time any set is logged. + val totalSetsBefore = _workoutState.value.setLog.values.sumOf { it.size } + if (totalSetsBefore == 0) startTimerOnFirstSet() + + dispatch(WorkoutAction.LogSet(exIndex, LoggedSet(weight = weight, reps = reps, trackingType = trackingType, durationSec = if (trackingType.startsWith("duration")) reps else null))) + val endTime = System.currentTimeMillis() + restSeconds * 1000L + dispatch(WorkoutAction.StartRest(endTime = endTime, total = restSeconds, triggerExIndex = exIndex)) + val setCountForExercise = _workoutState.value.setLog[exIndex]?.size ?: 0 + viewModelScope.launch { + settingsRepo.setString("active_workout_set_label", "Set $setCountForExercise") + settingsRepo.setString("active_workout_rest_end_ms", endTime.toString()) + } + // No separate rest timer notification — the foreground service notification shows rest seconds inline + // (see WorkoutForegroundService.buildNotification which reads active_workout_rest_end_ms). + + viewModelScope.launch { + val workoutExId = resolveWorkoutExerciseId(exIndex, exerciseId) + if (workoutExId != null) { + try { + workoutRepo.addSet(workoutExId, SetInput(weight = weight, reps = reps, restSeconds = restSeconds)) + checkForPr(exerciseId, weight, reps.roundToInt()) + } catch (_: Exception) { /* persist failure is non-fatal */ } + } + persistDraft(_workoutState.value) + } + } + + fun persistSetRpe(exIndex: Int, exerciseId: String, setIndexZeroBased: Int, rpe: Double?) { + persistSetUpdate(exIndex, exerciseId, setIndexZeroBased, SetInput(rpe = rpe)) + } + + fun persistSetRir(exIndex: Int, exerciseId: String, setIndexZeroBased: Int, rir: Int?) { + persistSetUpdate(exIndex, exerciseId, setIndexZeroBased, SetInput(rir = rir?.toDouble())) + } + + fun persistSetType(exIndex: Int, exerciseId: String, setIndexZeroBased: Int, type: String) { + val key = type.lowercase() + val input = SetInput( + isWarmup = key == "warmup", + isDropset = key == "drop", + isAmrap = key == "amrap", + toFailure = key == "failure", + ) + persistSetUpdate(exIndex, exerciseId, setIndexZeroBased, input) + } + + fun persistSetValues(exIndex: Int, exerciseId: String, setIndexZeroBased: Int, weight: Double?, reps: Double?) { + persistSetUpdate(exIndex, exerciseId, setIndexZeroBased, SetInput(weight = weight, reps = reps)) + } + + fun persistWarmupSets(exIndex: Int, exerciseId: String, warmups: List, restSeconds: Int = 45) { + if (warmups.isEmpty()) return + viewModelScope.launch { + val workoutExId = resolveWorkoutExerciseId(exIndex, exerciseId) ?: return@launch + warmups.forEach { ws -> + runCatching { + workoutRepo.addSet( + workoutExId, + SetInput( + weight = ws.weight, + reps = ws.reps, + restSeconds = restSeconds, + isWarmup = true, + rpe = ws.rpe, + rir = ws.rir?.toDouble(), + ), + ) + } + } + } + } + + private fun persistSetUpdate(exIndex: Int, exerciseId: String, setIndexZeroBased: Int, input: SetInput) { + val setOrder = setIndexZeroBased + 1 + viewModelScope.launch { + val workoutExId = resolveWorkoutExerciseId(exIndex, exerciseId) ?: return@launch + runCatching { + workoutRepo.updateSetByWorkoutExerciseAndOrder(workoutExId, setOrder, input) + } + } + } + + private suspend fun resolveWorkoutExerciseId(exIndex: Int, exerciseId: String): String? { + workoutExerciseIdByIndex[exIndex]?.let { return it } + val workoutId = waitForActiveWorkoutId() ?: return null + val existing = runCatching { + val detail = workoutRepo.getWorkoutDetailSnapshot(workoutId) + bindWorkoutExerciseRows(detail.exercises) + workoutExerciseIdByIndex[exIndex] + }.getOrNull() + if (!existing.isNullOrBlank()) { + return existing + } + val created = runCatching { workoutRepo.addExerciseToWorkout(workoutId, exerciseId) }.getOrNull() + if (created != null) { + workoutExerciseIdByIndex = workoutExerciseIdByIndex + (exIndex to created.uid) + return created.uid + } + return null + } + + private suspend fun waitForActiveWorkoutId(): String? { + activeWorkoutId?.let { return it } + repeat(20) { + delay(50) + activeWorkoutId?.let { return it } + } + return null + } + + private fun bindWorkoutExerciseRows(rows: List, exerciseIdsInUiOrder: List? = null) { + val orderedRows = rows.sortedBy { it.orderIndex } + val ids = exerciseIdsInUiOrder ?: orderedRows.map { it.exerciseUid } + workoutExerciseIdByIndex = buildWorkoutExerciseIndexMap( + exerciseIdsInUiOrder = ids, + workoutExerciseRows = orderedRows.map { + WorkoutExerciseBinding( + workoutExerciseId = it.uid, + exerciseId = it.exerciseUid, + orderIndex = it.orderIndex, + ) + }, + ) + } + + private fun checkForPr(exerciseId: String, weight: Double, reps: Int) { + val oneRm = estimateOneRM(weight, reps).toDouble() + val prev = historicalBest1rm[exerciseId] + if (prev == null || oneRm > prev) { + historicalBest1rm[exerciseId] = oneRm + hadPrThisSession = true + if (prev != null) { + val oneRmDisplay = com.ironlog.app.util.formatWeightFromKg(oneRm, vmWeightUnit) + _prBanner.value = "🏆 New PR! ~$oneRmDisplay 1RM" + viewModelScope.launch { + delay(4000) + _prBanner.value = null + } + } + } + } + + fun finishWorkout() { + _showCompletionSheet.value = true + } + + /** + * Persist workout completion, then call [onDone]. + * + * [onDone] must be invoked INSIDE the coroutine, AFTER all DB work completes. + * Calling it outside (as the previous code did) would destroy the NavBackStackEntry / + * ViewModel mid-coroutine, cancelling the clearActiveWorkoutId() call and leaving the + * floating pill stuck on-screen. + */ + fun completeWorkout( + rating: Int, + totalVolumeKg: Double, + notes: String = "", + onError: (String) -> Unit = {}, + onDone: (WorkoutCompletionCelebration) -> Unit = {}, + ) { + viewModelScope.launch { + val id = activeWorkoutId + if (id.isNullOrBlank()) { + _showCompletionSheet.value = false + onError("No active workout was found.") + return@launch + } + var streakDays = 0 + val hadPr = hadPrThisSession + try { + workoutRepo.completeWorkout( + workoutId = id, + durationStartEpochMs = timerStartEpochMs, + metadata = com.ironlog.app.data.model.WorkoutMetadataInput( + rating = if (rating in 1..5) rating.toDouble() else null, + notes = notes.ifBlank { null }, + ), + ) + } catch (e: Exception) { + Timber.e(e, "Failed to complete workout %s", id) + _showCompletionSheet.value = false + onError(e.message ?: "The workout could not be completed. Your active session was preserved.") + return@launch + } + + // The durable workout and active-session keys are committed atomically. Clear the + // in-memory ID immediately so screen disposal cannot recreate the deleted draft. + activeWorkoutId = null + _activeWorkoutIdSignal.value = null + streakDays = computeDailyWorkoutStreakDays() + + val metrics = runCatching { + workoutRepo.recordPostWorkoutMetrics(id, totalVolumeKg, hadPr) + }.onFailure { Timber.e(it, "Failed to record post-workout metrics for %s", id) } + .getOrNull() + if (metrics?.newlyRecorded == true) { + runCatching { + milestoneService.registerPostWorkout( + totalVolumeKg = metrics.cumulativeVolumeKg, + streakDays = streakDays, + newPr = hadPr, + ) + }.onFailure { Timber.e(it, "Failed to register workout milestones for %s", id) } + } + + runCatching { WorkoutNotificationBridge.clearWorkout(getApplication()) } + runCatching { WorkoutForegroundService.stop(getApplication()) } + runCatching { com.ironlog.app.widget.WidgetUpdateWorker.enqueueOneTime(getApplication()) } + timerStartEpochMs = null + _elapsedSeconds.value = 0 + _timerStarted.value = false + _showCompletionSheet.value = false + onDone( + WorkoutCompletionCelebration( + hasPrCelebration = hadPr, + hasStreak30Celebration = streakDays >= 30, + ), + ) + } + } + + private fun computeDailyWorkoutStreakDays(): Int { + val completed = ObjectBox.store.boxFor(WorkoutEntity::class.java).query(WorkoutEntity_.status.equal("completed")) + .build().use { it.find() } + if (completed.isEmpty()) return 0 + val dates = completed.map { + Instant.ofEpochMilli(it.startedAt).atZone(ZoneId.systemDefault()).toLocalDate() + }.toSet() + val today = LocalDate.now() + val yesterday = today.minusDays(1) + if (!dates.contains(today) && !dates.contains(yesterday)) return 0 + var streak = 0 + var cursor = if (dates.contains(today)) today else yesterday + while (dates.contains(cursor)) { + streak++ + cursor = cursor.minusDays(1) + } + return streak + } + + /** + * Mark the active workout as abandoned (clears active_workout_id and related settings keys), + * then call [onDone] once the DB write is confirmed. + */ + fun discardWorkout(onError: (String) -> Unit = {}, onDone: () -> Unit = {}) { + viewModelScope.launch { + val id = activeWorkoutId + if (!id.isNullOrBlank()) { + try { + workoutRepo.abandonWorkout(id) + } catch (e: Exception) { + Timber.e(e, "Failed to abandon workout %s", id) + onError(e.message ?: "The workout could not be discarded. Your session was preserved.") + return@launch + } + } + activeWorkoutId = null + _activeWorkoutIdSignal.value = null + WorkoutNotificationBridge.clearWorkout(getApplication()) + WorkoutForegroundService.stop(getApplication()) + settingsRepo.removeSetting("active_workout_set_label") + settingsRepo.removeSetting("active_workout_rest_end_ms") + settingsRepo.removeSetting("active_workout_start_ms") + timerStartEpochMs = null + _elapsedSeconds.value = 0 + _timerStarted.value = false + dispatch(WorkoutAction.HydrateState(WorkoutState())) + onDone() + } + } + + fun onRestCleared() { + WorkoutNotificationBridge.clearRestTimer(getApplication()) + viewModelScope.launch { settingsRepo.setString("active_workout_rest_end_ms", "0") } + } + + private fun draftPayload(state: WorkoutState): WorkoutDraftDto = WorkoutDraftDto( + inputs = state.inputs.mapKeys { it.key.toString() }.mapValues { (_, v) -> WorkoutInputDto(v.weight, v.reps) }, + setLog = state.setLog.mapKeys { it.key.toString() }.mapValues { (_, sets) -> + sets.map { + LoggedSetDto( + id = it.id, + weight = it.weight, + reps = it.reps, + type = it.type, + rpe = it.rpe, + rir = it.rir, + note = it.note, + orm = it.orm, + trackingType = it.trackingType, + durationSec = it.durationSec, + ) + } + }, + exerciseNotes = state.exerciseNotes.mapKeys { it.key.toString() }, + supersetGroups = state.supersetGroups.mapKeys { it.key.toString() }, + restTimer = RestTimerDto( + active = state.restTimer.active, + endTime = state.restTimer.endTime, + total = state.restTimer.total, + paused = state.restTimer.paused, + pausedAt = state.restTimer.pausedAt, + triggerExIndex = state.restTimer.triggerExIndex, + ), + addedExercises = state.addedExercises.map { + AddedExerciseDto( + exerciseId = it.exerciseId, + name = it.name, + trackingType = it.trackingType, + equipment = it.equipment, + sets = it.sets, + reps = it.reps, + ) + }, + removedBaseExerciseIndices = state.removedBaseExerciseIndices.toList().sorted(), + orderedIndices = persistedOrderedIndices, + swappedExercises = state.swappedExercises.mapNotNull { (k, v) -> + val ex = v as? com.ironlog.app.data.model.LegacyExerciseShape ?: return@mapNotNull null + k.toString() to SwappedExerciseDto( + id = ex.id, + name = ex.name, + trackingType = ex.trackingType, + equipment = ex.equipment, + ) + }.toMap(), + ) + + fun persistDraft(state: WorkoutState) { + viewModelScope.launch { + val id = activeWorkoutId ?: return@launch + val payload = draftPayload(state) + settingsRepo.setString("active_workout_draft_$id", json.encodeToString(payload), "json") + } + } + + fun persistDraftBlocking(state: WorkoutState) { + val id = activeWorkoutId ?: return + val payload = draftPayload(state) + runCatching { + settingsRepo.setStringBlocking("active_workout_draft_$id", json.encodeToString(payload), "json") + } + } + + private fun restoreDraftIfAny() { + viewModelScope.launch { + val id = activeWorkoutId ?: return@launch + val raw = settingsRepo.getString("active_workout_draft_$id").orEmpty() + if (raw.isBlank()) return@launch + val dto = runCatching { json.decodeFromString(WorkoutDraftDto.serializer(), raw) }.getOrNull() ?: return@launch + val restored = WorkoutState( + inputs = dto.inputs.mapNotNull { (k, v) -> k.toIntOrNull()?.let { it to com.ironlog.app.ui.state.WorkoutInput(v.weight, v.reps) } }.toMap(), + setLog = dto.setLog.mapNotNull { (k, sets) -> + k.toIntOrNull()?.let { idx -> + idx to sets.map { + com.ironlog.app.ui.state.LoggedSet( + id = it.id, + weight = it.weight, + reps = it.reps, + type = it.type, + rpe = it.rpe, + rir = it.rir, + note = it.note, + orm = it.orm, + trackingType = it.trackingType, + durationSec = it.durationSec, + ) + } + } + }.toMap(), + exerciseNotes = dto.exerciseNotes.mapNotNull { (k, v) -> k.toIntOrNull()?.let { it to v } }.toMap(), + supersetGroups = dto.supersetGroups.mapNotNull { (k, v) -> k.toIntOrNull()?.let { it to v } }.toMap(), + restTimer = com.ironlog.app.ui.state.RestTimerState( + active = dto.restTimer.active, + endTime = dto.restTimer.endTime, + total = dto.restTimer.total, + paused = dto.restTimer.paused, + pausedAt = dto.restTimer.pausedAt, + triggerExIndex = dto.restTimer.triggerExIndex, + ), + addedExercises = dto.addedExercises.map { + AddedExerciseEntry( + exerciseId = it.exerciseId, + name = it.name, + trackingType = it.trackingType, + equipment = it.equipment, + sets = it.sets, + reps = it.reps, + ) + }, + removedBaseExerciseIndices = dto.removedBaseExerciseIndices.toSet(), + ) + // Restore swapped-exercise overlays. Reconstruct a minimal LegacyExerciseShape so the UI + // shows the swapped exercise name/trackingType immediately on resume. + val restoredSwaps: Map = dto.swappedExercises.mapNotNull { (idxStr, sw) -> + val idx = idxStr.toIntOrNull() ?: return@mapNotNull null + idx to com.ironlog.app.data.model.LegacyExerciseShape( + id = sw.id, + exerciseId = sw.id, + name = sw.name, + primaryMuscles = emptyList(), + primaryMuscle = null, + secondaryMuscles = emptyList(), + equipment = sw.equipment, + category = "", + trackingType = sw.trackingType, + isCustom = false, + aliases = emptyList(), + isBodyweight = false, + movementPattern = null, + difficulty = null, + apparatus = null, + equipmentDetail = null, + sourceTags = emptyList(), + notes = "", + ) + }.toMap() + + dispatch(WorkoutAction.HydrateState(restored.copy(swappedExercises = restoredSwaps))) + + // Restore exercise display order if persisted. + if (dto.orderedIndices.isNotEmpty()) { + persistedOrderedIndices = dto.orderedIndices + _restoredOrderedIndices.value = dto.orderedIndices + } + + // If the draft already contains sets the timer should already be running + // (the user logged sets before backgrounding the app). + val hasExistingSets = restored.setLog.values.any { it.isNotEmpty() } + if (hasExistingSets) startTimerOnFirstSet() + } + } + + fun dismissCompletionSheet() { + _showCompletionSheet.value = false + } + + fun swapExercise(exIndex: Int, activeExerciseId: String, newExerciseId: String) { + viewModelScope.launch { + val workoutExId = resolveWorkoutExerciseId(exIndex, activeExerciseId) ?: return@launch + runCatching { workoutRepo.swapWorkoutExercise(workoutExId, newExerciseId) } + workoutExerciseIdByIndex = workoutExerciseIdByIndex + (exIndex to workoutExId) + } + } + + fun addExerciseToWorkout(exIndex: Int, exerciseId: String) { + viewModelScope.launch { + val wId = waitForActiveWorkoutId() ?: return@launch + runCatching { + val we = workoutRepo.addExerciseToWorkout(wId, exerciseId) + workoutExerciseIdByIndex = workoutExerciseIdByIndex + (exIndex to we.uid) + } + } + } + + fun persistSuperset(exIndex: Int, exerciseId: String, group: String?) { + viewModelScope.launch { + val workoutExId = resolveWorkoutExerciseId(exIndex, exerciseId) ?: return@launch + runCatching { workoutRepo.updateWorkoutExerciseSuperset(workoutExId, group) } + } + } + + fun rehydrateSetLogFromDatabase(exerciseIdsInUiOrder: List) { + val workoutId = activeWorkoutId ?: return + if (exerciseIdsInUiOrder.isEmpty()) return + viewModelScope.launch { + val detail = runCatching { workoutRepo.getWorkoutDetailSnapshot(workoutId) }.getOrNull() ?: return@launch + bindWorkoutExerciseRows(detail.exercises, exerciseIdsInUiOrder) + if (detail.sets.isEmpty()) return@launch + + val grouped = detail.sets + .filter { !it.isWarmup || it.weight > 0.0 || it.reps > 0.0 } + .groupBy { it.workoutExerciseUid } + if (grouped.isEmpty()) return@launch + + val mergedSetLog = _workoutState.value.setLog.toMutableMap() + workoutExerciseIdByIndex.forEach { (uiIndex, workoutExerciseId) -> + val dbSets = grouped[workoutExerciseId] + .orEmpty() + .sortedBy { it.setIndex } + .map { row -> + LoggedSet( + id = row.uid.ifBlank { UUID.randomUUID().toString().replace("-", "").take(12) }, + weight = row.weight, + reps = row.reps, + type = when { + row.isWarmup -> "warmup" + row.isDropset -> "drop" + row.isAmrap -> "amrap" + row.toFailure -> "failure" + else -> "normal" + }, + rpe = row.rpe, + rir = row.rir?.roundToInt(), + trackingType = "weight_reps", + durationSec = null, + orm = if (row.weight > 0.0 && row.reps > 0.0) row.weight * (1.0 + (row.reps / 30.0)) else 0.0, + ) + } + if (dbSets.isEmpty()) return@forEach + + val current = mergedSetLog[uiIndex].orEmpty() + if (dbSets.size >= current.size) { + mergedSetLog[uiIndex] = dbSets + } + } + + val currentState = _workoutState.value + if (mergedSetLog != currentState.setLog) { + _workoutState.value = currentState.copy(setLog = mergedSetLog) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ActiveWorkoutScreen( + dayId: String = "", + weightUnit: String = "kg", + effortTracking: String = "off", + hapticFeedback: Boolean = true, + onFinish: (() -> Unit)? = null, + onMinimize: (() -> Unit)? = null, + vm: ActiveWorkoutViewModel = viewModel(), + plansVm: PlansViewModel = viewModel(), +) { + val c = useTheme() + val context = LocalContext.current + val appVm: AppDataViewModel = viewModel() + val appState by appVm.state.collectAsState() + val appSettings = appState.settings + val cloudApiKey = remember(appSettings.cloudAiProviderPreset) { + CloudAiKeyStore.load(context, appSettings.cloudAiProviderPreset) + } + val view = LocalView.current + val lifecycleOwner = LocalLifecycleOwner.current + val settingsRepo = remember { SettingsRepository() } + val state by vm.workoutState.collectAsState() + val timerStarted by vm.timerStarted.collectAsState() + val prBanner by vm.prBanner.collectAsState() + val showCompletionSheet by vm.showCompletionSheet.collectAsState() + val activeWorkoutId by vm.activeWorkoutIdSignal.collectAsState() + val plans by plansVm.plans.collectAsState() + val restoredOrderedIndices by vm.restoredOrderedIndices.collectAsState() + val exerciseRepo = remember { ExerciseRepository(context.applicationContext) } + var swapTargetIndex by remember { mutableStateOf(null) } + var swapQuery by remember { mutableStateOf("") } + var showSaveToPlanPrompt by remember { mutableStateOf(false) } + var pendingOnFinish by remember { mutableStateOf<(() -> Unit)?>(null) } + var pendingCelebration by remember { mutableStateOf(null) } + var isFinalizingWorkout by remember { mutableStateOf(false) } + var isSavingAddedExercises by remember { mutableStateOf(false) } + var workoutActionError by remember { mutableStateOf(null) } + var showCompletionConfetti by remember { mutableStateOf(false) } + var confettiBurstId by remember { mutableStateOf(0) } + val planRepo = remember { com.ironlog.app.data.repository.PlanRepository() } + val scope = rememberCoroutineScope() + var exercisePool by remember { mutableStateOf>(emptyList()) } + var showAddExerciseSheet by remember { mutableStateOf(false) } + var addExerciseQuery by remember { mutableStateOf("") } + var restOverride by remember { mutableStateOf>(emptyMap()) } + var editingRestExIndex by remember { mutableStateOf(null) } + var restOverrideInput by remember { mutableStateOf("") } + var restPickerMinutes by remember { mutableStateOf(1) } + var restPickerSeconds by remember { mutableStateOf(30) } + var defaultRestNormalSec by remember { mutableStateOf(90) } + var defaultRestHeavySec by remember { mutableStateOf(180) } + var settingsBarWeightKg by remember { mutableStateOf(20.0) } + var keepAwakeDuringWorkout by remember { mutableStateOf(true) } + var activeGymProfile by remember { mutableStateOf(null) } + // Reorder state — orderedIndices mirrors RN's orderedIndices pattern + var orderedIndices by remember { mutableStateOf>(emptyList()) } + val lazyListState = rememberLazyListState() + var scrollToNextSupersetIndex by remember { mutableStateOf(null) } + LaunchedEffect(scrollToNextSupersetIndex) { + scrollToNextSupersetIndex?.let { pos -> + lazyListState.animateScrollToItem(pos) + scrollToNextSupersetIndex = null + } + } + val reorderState = rememberReorderableLazyListState(lazyListState) { from, to -> + // The list has 2 non-exercise items before exercises (header + PR banner), + // so adjust index by subtracting that offset. + val offset = 2 + val fromIdx = from.index - offset + val toIdx = to.index - offset + if (fromIdx in orderedIndices.indices && toIdx in orderedIndices.indices) { + orderedIndices = orderedIndices.toMutableList().apply { + add(toIdx, removeAt(fromIdx)) + } + } + } + + // Resolve day and plan from plans VM using dayId + val resolvedPair: Pair = remember(dayId, plans) { + if (dayId.isBlank()) { + Pair(null, null) + } else { + var result: Pair = Pair(null, null) + for (plan in plans) { + val day = plan.days.firstOrNull { it.id == dayId } + if (day != null) { result = Pair(day, plan); break } + } + result + } + } + val resolvedDay = resolvedPair.first + val resolvedPlan = resolvedPair.second + + val baseExerciseEntries = remember(resolvedDay, state.removedBaseExerciseIndices) { + (resolvedDay?.exercises.orEmpty()).mapIndexedNotNull { originalIndex, planEx -> + if (originalIndex in state.removedBaseExerciseIndices) { + null + } else { + originalIndex to normalizeSessionExercise(planEx, null) + } + } + } + val baseOriginalIndices = remember(baseExerciseEntries) { baseExerciseEntries.map { it.first } } + val baseExercises = remember(baseExerciseEntries) { baseExerciseEntries.map { it.second } } + val addedAsNormalized = remember(state.addedExercises) { + state.addedExercises.map { entry -> + NormalizedSessionExercise( + name = entry.name, + exerciseId = entry.exerciseId, + sets = entry.sets, + reps = entry.reps, + trackingType = entry.trackingType, + isWarmup = false, + equipment = entry.equipment, + ) + } + } + val exercises = remember(baseExercises, state.swappedExercises, addedAsNormalized) { + val swapped = baseExercises.mapIndexed { idx, base -> + val sw = state.swappedExercises[idx] as? LegacyExerciseShape + if (sw == null) base else base.copy( + name = sw.name, + exerciseId = sw.id.ifBlank { sw.exerciseId }, + trackingType = sw.trackingType.ifBlank { base.trackingType }, + equipment = sw.equipment ?: base.equipment, + ) + } + swapped + addedAsNormalized + } + + LaunchedEffect(dayId) { + vm.initWorkout(dayId) + } + // Sync orderedIndices from the ViewModel when a draft is restored after minimize/resume. + LaunchedEffect(restoredOrderedIndices) { + if (restoredOrderedIndices.isNotEmpty() && restoredOrderedIndices.size == exercises.size) { + orderedIndices = restoredOrderedIndices + } + } + LaunchedEffect(exercises) { + // Load ghost (last-session) data whenever the exercise list changes + if (exercises.isNotEmpty()) { + vm.loadGhostData(exercises.map { it.exerciseId }) + } + when { + // Initial setup: exercises just appeared for the first time. + orderedIndices.isEmpty() && exercises.isNotEmpty() -> { + orderedIndices = exercises.indices.toList() + } + // Exercise(s) added mid-workout: append the new index at the end. + exercises.size > orderedIndices.size -> { + val newIndices = (orderedIndices.size until exercises.size).toList() + orderedIndices = orderedIndices + newIndices + } + // Removal is handled at the call site (onRemove lambda) to preserve order. + } + } + // Keep VM in sync with the latest orderedIndices so persistDraft always saves them. + LaunchedEffect(orderedIndices) { + vm.updateOrderedIndices(orderedIndices) + } + // Rehydrate set log from DB only on first load or when NEW exercises are added/swapped. + // Removals are handled exclusively by the reducer+dispatch path — re-running rehydrate + // on removal races with the async DB delete and can re-insert stale workout exercise + // bindings, making the exercise appear to "not delete". + var lastHydratedExerciseIds by remember { mutableStateOf>(emptySet()) } + LaunchedEffect(activeWorkoutId, exercises) { + val currentIds = exercises.map { it.exerciseId }.toSet() + val hasNewIds = currentIds.any { it !in lastHydratedExerciseIds } + val isFirstLoad = lastHydratedExerciseIds.isEmpty() + if (!activeWorkoutId.isNullOrBlank() && exercises.isNotEmpty() && (isFirstLoad || hasNewIds)) { + vm.rehydrateSetLogFromDatabase(exercises.map { it.exerciseId }) + } + lastHydratedExerciseIds = currentIds + } + LaunchedEffect(Unit) { + exercisePool = runCatching { exerciseRepo.getExercisesSnapshot() }.getOrElse { emptyList() } + keepAwakeDuringWorkout = settingsRepo.getBoolean("keep_screen_awake_active_workout", true) + val rawSettings = settingsRepo.getString("ironlog_settings") + runCatching { + val json = org.json.JSONObject(rawSettings ?: "{}") + defaultRestNormalSec = json.optInt("defaultRestSeconds", 90).coerceIn(15, 600) + defaultRestHeavySec = json.optInt("defaultRestHeavySeconds", 180).coerceIn(30, 900) + settingsBarWeightKg = json.optDouble("barWeightKg", 20.0).coerceIn(0.0, 100.0) + } + val raw = settingsRepo.getString("gym_profiles_json").orEmpty() + val profiles = runCatching { Json { ignoreUnknownKeys = true }.decodeFromString>(raw) }.getOrDefault(emptyList()) + val activeId = settingsRepo.getString("active_gym_profile_id") + activeGymProfile = profiles.firstOrNull { it.id == activeId } ?: profiles.firstOrNull() + } + LaunchedEffect(activeWorkoutId) { + val id = activeWorkoutId ?: return@LaunchedEffect + val raw = settingsRepo.getString("active_workout_rest_override_$id").orEmpty() + if (raw.isNotBlank()) { + val restored = runCatching { + val obj = org.json.JSONObject(raw) + obj.keys().asSequence().mapNotNull { k -> + k.toIntOrNull()?.let { idx -> idx to obj.optInt(k, 0).coerceIn(15, 900) } + }.toMap() + }.getOrDefault(emptyMap()) + if (restored.isNotEmpty()) restOverride = restored + } + } + LaunchedEffect(activeWorkoutId, restOverride) { + val id = activeWorkoutId ?: return@LaunchedEffect + val obj = org.json.JSONObject() + restOverride.forEach { (k, v) -> obj.put(k.toString(), v) } + settingsRepo.setString("active_workout_rest_override_$id", obj.toString(), "json") + } + val latestState by rememberUpdatedState(state) + DisposableEffect(lifecycleOwner, activeWorkoutId) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP && !activeWorkoutId.isNullOrBlank()) { + vm.persistDraftBlocking(latestState) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + DisposableEffect(activeWorkoutId) { + val idForEffect = activeWorkoutId + onDispose { + if (!idForEffect.isNullOrBlank()) vm.persistDraftBlocking(latestState) + } + } + LaunchedEffect(Unit) { + while (true) { + val action = withContext(Dispatchers.IO) { + val a = settingsRepo.getString("pending_workout_action").orEmpty() + if (a.isNotBlank()) settingsRepo.removeSetting("pending_workout_action") + a + } + if (action.isNotBlank()) { + when (action) { + com.ironlog.app.services.NotificationActionRouter.Actions.ADD_30S -> { + vm.dispatch(WorkoutAction.Add30s) + } + com.ironlog.app.services.NotificationActionRouter.Actions.SKIP_REST -> { + vm.dispatch(WorkoutAction.SkipRest) + vm.onRestCleared() + } + com.ironlog.app.services.NotificationActionRouter.Actions.FINISH_WORKOUT -> { + vm.finishWorkout() + } + } + } + delay(900) + } + } + BackHandler(enabled = true) { + if (showCompletionSheet) vm.dismissCompletionSheet() else onMinimize?.invoke() + } + DisposableEffect(view, keepAwakeDuringWorkout) { + val prev = view.keepScreenOn + if (keepAwakeDuringWorkout) view.keepScreenOn = true + onDispose { view.keepScreenOn = prev } + } + + Box(Modifier.fillMaxSize().background(c.bg).statusBarsPadding()) { + // FIXED: 14 — contentPadding bottom expands when rest timer is active so it doesn't cover FINISH WORKOUT + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = if (state.restTimer.active) 130.dp else 80.dp), + ) { + item { + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + shape = RoundedCornerShape(IronLogRadius.xl.dp), + ) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + // ── Row 1: name + close/menu ────────────────────────── + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(Modifier.weight(1f).padding(end = 8.dp)) { + Text( + resolvedDay?.name ?: resolvedPlan?.name ?: "Workout", + color = c.text, + fontWeight = FontWeight(IronLogType.section.fontWeight), + fontSize = IronLogType.section.fontSize.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "${exercises.size} exercise${if (exercises.size == 1) "" else "s"}", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + var showHeaderMenu by remember { mutableStateOf(false) } + Box { + androidx.compose.material3.IconButton(onClick = { showHeaderMenu = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = "Menu", tint = c.muted) + } + androidx.compose.material3.DropdownMenu(showHeaderMenu, onDismissRequest = { showHeaderMenu = false }) { + androidx.compose.material3.DropdownMenuItem( + text = { Text("Minimize", color = c.text) }, + onClick = { showHeaderMenu = false; onMinimize?.invoke() }, + ) + androidx.compose.material3.DropdownMenuItem( + text = { Text("Discard Workout", color = c.danger) }, + onClick = { + showHeaderMenu = false + vm.discardWorkout( + onError = { workoutActionError = it }, + onDone = { onFinish?.invoke() }, + ) + }, + ) + } + } + // FIXED: 12 — Duplicate FINISH button removed from header; only FINISH WORKOUT at bottom + } + } + // ── Row 2: timer + elapsed + minimize ───────────────── + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + Modifier + .background(c.faint, RoundedCornerShape(IronLogRadius.full.dp)) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + ActiveWorkoutRollingTimerText(vm, c) + } + Text("elapsed", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + Text( + "MINIMIZE", + color = c.accent, + fontWeight = FontWeight.ExtraBold, + fontSize = IronLogType.button.fontSize.sp, + letterSpacing = 1.sp, + modifier = Modifier.clickable { onMinimize?.invoke() }.padding(horizontal = 8.dp, vertical = 10.dp), + ) + } + // FIXED: 15 — Live volume comparison vs previous session + val currentVolumeKg = remember(state.setLog) { + state.setLog.values.flatten() + .filter { it.type.ifBlank { "normal" } != "warmup" } + .sumOf { it.weight * it.reps } + } + val previousVolumeKg = remember(state.ghostData) { + state.ghostData.values + .flatMap { it.sets } + .filter { it.type.ifBlank { "normal" } != "warmup" } + .sumOf { it.weight * it.reps } + } + if (currentVolumeKg > 0) { + val delta = currentVolumeKg - previousVolumeKg + val arrow = if (delta >= 0) "↑" else "↓" + val volStr = formatWeightFromKg(currentVolumeKg, weightUnit) + val deltaColor = if (previousVolumeKg <= 0 || delta >= 0) c.success else c.danger + Text( + "You've lifted $arrow $volStr${if (previousVolumeKg > 0) " (${if (delta >= 0) "+" else ""}${formatWeightFromKg(delta, weightUnit)} vs prev)" else ""}", + color = deltaColor, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.Medium, + ) + } + } + } + } + item { + AnimatedVisibility(visible = prBanner != null, enter = fadeIn(), exit = fadeOut()) { + prBanner?.let { msg -> + Row( + Modifier + .fillMaxWidth() + .background(c.gold.copy(alpha = 0.15f), RoundedCornerShape(IronLogRadius.lg.dp)) + .border(1.dp, c.gold.copy(alpha = 0.4f), RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Outlined.EmojiEvents, contentDescription = null, tint = c.gold, modifier = Modifier.size(20.dp)) + Text( + msg, + color = c.gold, + fontWeight = FontWeight(IronLogType.section.fontWeight), + fontSize = IronLogType.section.fontSize.sp, + ) + } + } + } + } + val displayIndices = if (orderedIndices.size == exercises.size) orderedIndices else exercises.indices.toList() + items(displayIndices, key = { idx -> + val ex = exercises.getOrNull(idx) + if (ex != null) "${ex.exerciseId}::$idx" else idx.toString() + }) { exIndex -> + val ex = exercises.getOrNull(exIndex) ?: return@items + val baseOriginalIndex = baseOriginalIndices.getOrNull(exIndex) + val defaultRestSec = baseOriginalIndex?.let { resolvedDay?.exercises?.getOrNull(it)?.restSeconds } + ?: if (isHeavyCompoundExercise(ex.name)) defaultRestHeavySec else defaultRestNormalSec + val effectiveRest = restOverride[exIndex] ?: defaultRestSec.coerceIn(15, 900) + val ghost = state.ghostData[exIndex] + val exerciseNote = state.exerciseNotes[exIndex].orEmpty() + val supersetGroup = state.supersetGroups[exIndex]?.ifBlank { null } + // Coordinated superset rest: only the last exercise in a group triggers the rest timer. + // Mid-superset exercises pass restSec=0 so the timer stays quiet between exercises. + val isLastInSuperset = if (supersetGroup.isNullOrBlank()) true else { + val groupIndices = displayIndices.filter { state.supersetGroups[it]?.ifBlank { null } == supersetGroup } + groupIndices.lastOrNull() == exIndex + } + val restForLogSet = if (isLastInSuperset) effectiveRest else 0 + ReorderableItem(reorderState, key = "${ex.exerciseId}::$exIndex") { isDragging -> + ExerciseCard( + exIndex = exIndex, + exercise = ex, + loggedSets = state.setLog[exIndex].orEmpty(), + input = state.inputs[exIndex]?.weight.orEmpty() to state.inputs[exIndex]?.reps.orEmpty(), + dispatch = vm::dispatch, + baseExercisesCount = baseExercises.size, + onRemoveExercise = { + // Update orderedIndices before dispatching so the LazyColumn + // key map stays consistent: drop the removed index and shift the rest. + orderedIndices = orderedIndices + .filter { it != exIndex } + .map { if (it > exIndex) it - 1 else it } + // Also re-index the rest-override map. + restOverride = restOverride + .filterKeys { it != exIndex } + .mapKeys { (k, _) -> if (k > exIndex) k - 1 else k } + vm.dispatch( + WorkoutAction.RemoveExercise( + exIndex = exIndex, + baseExercisesCount = baseExercises.size, + removedBaseIndex = baseOriginalIndex, + ) + ) + }, + onLogSet = { weight, reps -> + vm.logSet(exIndex, ex.exerciseId, weight, reps, ex.trackingType, restForLogSet) + // GAP-01: superset auto-rotation — scroll to next exercise in group + if (!isLastInSuperset && !supersetGroup.isNullOrBlank()) { + val currentPosInDisplay = displayIndices.indexOf(exIndex) + val nextExIndex = displayIndices.drop(currentPosInDisplay + 1) + .firstOrNull { state.supersetGroups[it]?.ifBlank { null } == supersetGroup } + if (nextExIndex != null) { + val nextPosInDisplay = displayIndices.indexOf(nextExIndex) + // +2 for: header card (item 0) + PR banner (item 1) + scrollToNextSupersetIndex = nextPosInDisplay + 2 + } + } + }, + weightUnit = weightUnit, + effortTracking = effortTracking, + hapticFeedback = hapticFeedback, + onSetRpeChanged = { setIndex, rpe -> vm.persistSetRpe(exIndex, ex.exerciseId, setIndex, rpe) }, + onSetRirChanged = { setIndex, rir -> vm.persistSetRir(exIndex, ex.exerciseId, setIndex, rir) }, + onSetTypeChanged = { setIndex, type -> vm.persistSetType(exIndex, ex.exerciseId, setIndex, type) }, + onSetValuesChanged = { setIndex, w, r -> vm.persistSetValues(exIndex, ex.exerciseId, setIndex, w, r) }, + onInsertWarmups = { warmups -> vm.persistWarmupSets(exIndex, ex.exerciseId, warmups) }, + onSwapRequest = { swapTargetIndex = exIndex; swapQuery = "" }, + restSec = effectiveRest, + onEditRest = { + editingRestExIndex = exIndex + restOverrideInput = effectiveRest.toString() + restPickerMinutes = (effectiveRest / 60).coerceIn(0, 10) + val rem = effectiveRest % 60 + restPickerSeconds = when { + rem < 8 -> 0 + rem < 23 -> 15 + rem < 38 -> 30 + else -> 45 + } + }, + ghost = ghost, + exerciseNote = exerciseNote, + supersetGroup = supersetGroup, + onSupersetChange = { group -> + vm.dispatch(WorkoutAction.AssignSuperset(exIndex, group)) + vm.persistSuperset(exIndex, ex.exerciseId, group) + }, + isDragging = isDragging, + dragHandleModifier = Modifier.draggableHandle(), + activeProfile = activeGymProfile, + settingsBarWeightKg = settingsBarWeightKg, + targetOverride = state.targetOverrides[exIndex], + ) + } + } + item { + // FIXED: 13 — ADD EXERCISE button styled interactive (accent tint) + Button( + onClick = { + if (hapticFeedback) HapticsEngine.lightConfirm(context) + addExerciseQuery = "" + showAddExerciseSheet = true + }, + colors = ButtonDefaults.buttonColors(containerColor = c.accent.copy(alpha = 0.10f)), + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(IronLogRadius.lg.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, c.accent.copy(alpha = 0.35f)), + ) { + Text( + "+ ADD EXERCISE", + color = c.accent, + fontWeight = FontWeight(IronLogType.button.fontWeight), + fontSize = IronLogType.meta.fontSize.sp, + ) + } + Spacer(Modifier.height(12.dp)) + val totalVolume = state.setLog.values.flatten().filter { it.type != "warmup" }.sumOf { it.weight * it.reps } + val comparison = getFunComparison(totalVolume) + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + shape = RoundedCornerShape(IronLogRadius.lg.dp), + ) { + Column(Modifier.padding(14.dp)) { + Text( + "Volume: ${formatWeightFromKg(totalVolume, weightUnit)}", + color = c.text, + fontWeight = FontWeight(IronLogType.section.fontWeight), + fontSize = IronLogType.section.fontSize.sp, + ) + Text( + "About ${comparison.text}", + color = c.muted, + fontSize = IronLogType.body.fontSize.sp, + ) + } + } + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + if (hapticFeedback) HapticsEngine.lightConfirm(context) + vm.finishWorkout() + }, + colors = ButtonDefaults.buttonColors(containerColor = c.accent), + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(IronLogRadius.lg.dp), + ) { + Text( + "FINISH WORKOUT", + color = c.textOnAccent, + fontWeight = FontWeight(IronLogType.button.fontWeight), + fontSize = IronLogType.section.fontSize.sp, + ) + } + Spacer(Modifier.height(8.dp)) + } + } + RestTimerPanel( + restTimer = state.restTimer, + modifier = Modifier.align(Alignment.BottomCenter).navigationBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp), + dispatch = vm::dispatch, + onRestCleared = vm::onRestCleared, + hapticFeedback = hapticFeedback, + ) + } + + if (showCompletionSheet) { + val elapsedSecondsForCompletion by vm.elapsedSeconds.collectAsState() + WorkoutCompletionSheet( + totalSets = state.setLog.values.sumOf { it.size }, + totalVolume = state.setLog.values.flatten().filter { it.type != "warmup" }.sumOf { it.weight * it.reps }, + durationSeconds = elapsedSecondsForCompletion, + weightUnit = weightUnit, + exerciseNames = exercises.map { it.name }, + planDayName = resolvedDay?.name ?: "Free Session", + goalMode = appSettings.goalMode, + cloudBaseUrl = appSettings.cloudAiBaseUrl, + cloudApiKey = cloudApiKey, + cloudModelName = appSettings.cloudAiModelName, + cloudApiFormat = appSettings.cloudAiApiFormat, + onComplete = { rating, notes -> + if (hapticFeedback) HapticsEngine.success(context) + isFinalizingWorkout = true + val volume = state.setLog.values.flatten().filter { it.type != "warmup" }.sumOf { it.weight * it.reps } + val hasAddedExercises = state.addedExercises.isNotEmpty() && !dayId.isBlank() + vm.completeWorkout( + rating = rating, + totalVolumeKg = volume, + notes = notes, + onError = { message -> + isFinalizingWorkout = false + workoutActionError = message + }, + ) { celebration -> + if (hasAddedExercises) { + pendingCelebration = celebration + pendingOnFinish = onFinish + showSaveToPlanPrompt = true + isFinalizingWorkout = false + } else { + isFinalizingWorkout = false + if (celebration.hasPrCelebration || celebration.hasStreak30Celebration) { + showCompletionConfetti = true + confettiBurstId++ + scope.launch { + delay(1750) + showCompletionConfetti = false + onFinish?.invoke() + } + } else { + onFinish?.invoke() + } + } + } + }, + onDismiss = { vm.dismissCompletionSheet() }, + ) + } + + if (showSaveToPlanPrompt && state.addedExercises.isNotEmpty()) { + val addedNames = state.addedExercises.joinToString(", ") { it.name } + fun continueAfterSavePrompt() { + val celebration = pendingCelebration + pendingCelebration = null + if (celebration?.hasPrCelebration == true || celebration?.hasStreak30Celebration == true) { + showCompletionConfetti = true + confettiBurstId++ + scope.launch { + delay(1750) + showCompletionConfetti = false + pendingOnFinish?.invoke() + pendingOnFinish = null + } + } else { + pendingOnFinish?.invoke() + pendingOnFinish = null + } + } + androidx.compose.material3.AlertDialog( + onDismissRequest = { + if (!isSavingAddedExercises) { + showSaveToPlanPrompt = false + continueAfterSavePrompt() + } + }, + title = { Text("Save to Plan?", color = c.text) }, + text = { + Text( + "You added exercises during this session ($addedNames). Add them to this plan day for next time?", + color = c.subtext, + ) + }, + confirmButton = { + androidx.compose.material3.TextButton( + enabled = !isSavingAddedExercises, + onClick = { + isSavingAddedExercises = true + scope.launch { + try { + state.addedExercises.forEach { entry -> + planRepo.addExerciseToPlanDay( + dayId, + com.ironlog.app.data.model.PlanExerciseInput( + exerciseId = entry.exerciseId, + sets = entry.sets, + reps = entry.reps.toString(), + ), + ) + } + showSaveToPlanPrompt = false + continueAfterSavePrompt() + } catch (error: Throwable) { + Timber.e(error, "Failed to save workout-added exercises to plan day %s", dayId) + workoutActionError = "Your workout was saved, but the added exercises could not be added to the plan. You can retry or choose Not Now." + } finally { + isSavingAddedExercises = false + } + } + }, + ) { Text(if (isSavingAddedExercises) "SAVING…" else "SAVE TO PLAN", color = c.accent) } + }, + dismissButton = { + androidx.compose.material3.TextButton( + enabled = !isSavingAddedExercises, + onClick = { + showSaveToPlanPrompt = false + continueAfterSavePrompt() + }, + ) { + Text("NOT NOW", color = c.muted) + } + }, + containerColor = c.card, + ) + } + + workoutActionError?.let { message -> + androidx.compose.material3.AlertDialog( + onDismissRequest = { workoutActionError = null }, + title = { Text("Couldn’t finish that action", color = c.text) }, + text = { Text(message, color = c.subtext) }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = { workoutActionError = null }) { + Text("OK", color = c.accent) + } + }, + containerColor = c.card, + ) + } + + if (showCompletionConfetti) { + ConfettiOverlay( + modifier = Modifier.fillMaxSize(), + burstId = confettiBurstId, + accent = c.accent, + ) + } + + if (isFinalizingWorkout) { + Surface( + modifier = Modifier.fillMaxSize(), + color = c.bg.copy(alpha = 0.92f), + ) { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator(color = c.accent) + Spacer(Modifier.height(16.dp)) + Text( + "Finalizing workout...", + color = c.text, + fontWeight = FontWeight.Bold, + fontSize = IronLogType.section.fontSize.sp, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Logging history and refreshing all insights.", + color = c.subtext, + textAlign = TextAlign.Center, + fontSize = IronLogType.body.fontSize.sp, + ) + } + } + } + + val targetIndex = swapTargetIndex + if (targetIndex != null) { + val targetExercise = exercises.getOrNull(targetIndex) + val filtered = exercisePool.filter { + val q = swapQuery.trim().lowercase() + q.isBlank() || it.name.lowercase().contains(q) || it.primaryMuscle.orEmpty().lowercase().contains(q) + }.take(40) + ModalBottomSheet( + onDismissRequest = { swapTargetIndex = null }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = c.card, + ) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Swap Exercise", color = c.text, fontWeight = FontWeight(IronLogType.title.fontWeight), fontSize = IronLogType.title.fontSize.sp) + Text(targetExercise?.name ?: "", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + OutlinedTextField( + value = swapQuery, + onValueChange = { swapQuery = it }, + label = { Text("Search exercise") }, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn(Modifier.fillMaxWidth().height(360.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (filtered.isEmpty()) { + item { + Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { + Text("No exercises found", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + } + } + } + itemsIndexed(filtered) { _, item -> + Card( + colors = CardDefaults.cardColors(containerColor = c.surface), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + modifier = Modifier.fillMaxWidth().clickable { + if (targetExercise != null) { + vm.dispatch(WorkoutAction.SwapExercise(targetIndex, item)) + vm.swapExercise(targetIndex, targetExercise.exerciseId, item.id.ifBlank { item.exerciseId }) + } + swapTargetIndex = null + }, + ) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text(item.name, color = c.text, fontWeight = FontWeight(IronLogType.section.fontWeight), fontSize = IronLogType.section.fontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${item.primaryMuscle ?: "Other"} · ${item.equipment}", color = c.muted, fontSize = IronLogType.meta.fontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + } + } + } + + val restExIdx = editingRestExIndex + if (restExIdx != null) { + val presets = listOf(30, 60, 90, 120, 180, 300) + val secOptions = listOf(0, 15, 30, 45) + val minState = rememberLazyListState(initialFirstVisibleItemIndex = restPickerMinutes) + val secState = rememberLazyListState(initialFirstVisibleItemIndex = secOptions.indexOf(restPickerSeconds).coerceAtLeast(0)) + ModalBottomSheet( + onDismissRequest = { editingRestExIndex = null }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = c.card, + ) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Rest Timer", + color = c.text, + fontWeight = FontWeight(IronLogType.title.fontWeight), + fontSize = IronLogType.title.fontSize.sp, + ) + Text( + "Override rest duration for this exercise", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + androidx.compose.foundation.lazy.LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + itemsIndexed(presets) { _, sec -> + val isSelected = (restPickerMinutes * 60 + restPickerSeconds) == sec + Box( + Modifier + .background( + if (isSelected) c.accent else c.surface, + RoundedCornerShape(IronLogRadius.full.dp), + ) + .border(1.dp, if (isSelected) c.accent else c.cardBorder, RoundedCornerShape(IronLogRadius.full.dp)) + .clickable { + restPickerMinutes = sec / 60 + restPickerSeconds = sec % 60 + } + .padding(horizontal = 14.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "${sec}s", + color = if (isSelected) c.textOnAccent else c.text, + fontSize = IronLogType.body.fontSize.sp, + fontWeight = FontWeight(IronLogType.button.fontWeight), + ) + } + } + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) { + Text("MIN", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + androidx.compose.foundation.lazy.LazyColumn( + state = minState, + flingBehavior = rememberSnapFlingBehavior(minState), + modifier = Modifier.height(120.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items(11) { m -> + val active = m == restPickerMinutes + Text( + m.toString(), + color = if (active) c.accent else c.subtext, + fontWeight = if (active) FontWeight.Bold else FontWeight.Normal, + fontSize = if (active) IronLogType.title.fontSize.sp else IronLogType.section.fontSize.sp, + modifier = Modifier.clickable { restPickerMinutes = m }, + ) + } + } + } + Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) { + Text("SEC", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + androidx.compose.foundation.lazy.LazyColumn( + state = secState, + flingBehavior = rememberSnapFlingBehavior(secState), + modifier = Modifier.height(120.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items(secOptions.size) { i -> + val value = secOptions[i] + val active = value == restPickerSeconds + Text( + value.toString().padStart(2, '0'), + color = if (active) c.accent else c.subtext, + fontWeight = if (active) FontWeight.Bold else FontWeight.Normal, + fontSize = if (active) IronLogType.title.fontSize.sp else IronLogType.section.fontSize.sp, + modifier = Modifier.clickable { restPickerSeconds = value }, + ) + } + } + } + } + Button( + onClick = { + val sec = (restPickerMinutes * 60 + restPickerSeconds).coerceIn(15, 600) + restOverride = restOverride + (restExIdx to sec) + editingRestExIndex = null + }, + colors = ButtonDefaults.buttonColors(containerColor = c.accent), + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(IronLogRadius.lg.dp), + ) { + Text("APPLY", color = c.textOnAccent, fontWeight = FontWeight(IronLogType.button.fontWeight)) + } + } + } + } + + if (showAddExerciseSheet) { + val filteredAdd = exercisePool.filter { + val q = addExerciseQuery.trim().lowercase() + q.isBlank() || it.name.lowercase().contains(q) || it.primaryMuscle.orEmpty().lowercase().contains(q) + }.take(50) + ModalBottomSheet( + onDismissRequest = { showAddExerciseSheet = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = c.card, + ) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Add Exercise", color = c.text, fontWeight = FontWeight(IronLogType.title.fontWeight), fontSize = IronLogType.title.fontSize.sp) + Text("${exercises.size} exercises in session", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + OutlinedTextField( + value = addExerciseQuery, + onValueChange = { addExerciseQuery = it }, + label = { Text("Search exercise") }, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn(Modifier.fillMaxWidth().height(400.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + itemsIndexed(filteredAdd) { _, item -> + Card( + colors = CardDefaults.cardColors(containerColor = c.surface), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + modifier = Modifier.fillMaxWidth().clickable { + val entry = AddedExerciseEntry( + exerciseId = item.id.ifBlank { item.exerciseId }, + name = item.name, + trackingType = item.trackingType.ifBlank { "weight_reps" }, + equipment = item.equipment, + ) + vm.dispatch(WorkoutAction.AddExercise(entry)) + vm.addExerciseToWorkout(exercises.size, entry.exerciseId) + showAddExerciseSheet = false + }, + ) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text(item.name, color = c.text, fontWeight = FontWeight(IronLogType.section.fontWeight), fontSize = IronLogType.section.fontSize.sp) + Text("${item.primaryMuscle ?: "Other"} · ${item.equipment}", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun WorkoutCompletionSheet( + totalSets: Int, + totalVolume: Double, + durationSeconds: Int, + weightUnit: String, + exerciseNames: List = emptyList(), + planDayName: String = "Free Session", + goalMode: String = "hypertrophy", + cloudBaseUrl: String = "", + cloudApiKey: String = "", + cloudModelName: String = "", + cloudApiFormat: String = "openai", + onComplete: (rating: Int, notes: String) -> Unit, + onDismiss: () -> Unit, +) { + val c = useTheme() + val context = LocalContext.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var selectedRating by remember { mutableStateOf(0) } + var workoutNotes by remember { mutableStateOf("") } + val cloudConfigured = cloudApiKey.isNotBlank() && cloudBaseUrl.isNotBlank() && cloudModelName.isNotBlank() + var debriefText by remember { mutableStateOf(null) } + var debriefLoading by remember { mutableStateOf(cloudConfigured) } + + LaunchedEffect(Unit) { + if (!cloudConfigured) return@LaunchedEffect + debriefLoading = true + debriefText = CloudAiEngine.askDayEvaluation( + baseUrl = cloudBaseUrl, + apiKey = cloudApiKey, + modelName = cloudModelName, + apiFormat = cloudApiFormat, + dayName = planDayName, + exerciseNames = exerciseNames, + goalMode = goalMode, + ) + debriefLoading = false + } + + // Fun comparison — pick highest threshold that doesn't exceed totalVolume (in kg) + val funComparison = remember(totalVolume) { + FUN_COMPARISONS.lastOrNull { it.threshold <= totalVolume.toInt() } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = c.card, + ) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 20.dp) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // ── Trophy icon in accent circle ──────────────────────────────── + Box(contentAlignment = Alignment.Center) { + Box( + Modifier + .size(72.dp) + .background(c.accent.copy(alpha = 0.14f), CircleShape) + .border(1.dp, c.accent.copy(alpha = 0.35f), CircleShape), + ) + Icon( + Icons.Outlined.EmojiEvents, + contentDescription = null, + tint = c.accent, + modifier = Modifier.size(36.dp), + ) + } + + // ── Eyebrow + title ───────────────────────────────────────────── + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "SESSION COMPLETE", + color = c.accent, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + Text( + "Great work. Keep it up.", + color = c.text, + fontWeight = FontWeight(IronLogType.title.fontWeight), + fontSize = IronLogType.title.fontSize.sp, + ) + } + + // ── Stats row ─────────────────────────────────────────────────── + Row( + Modifier + .fillMaxWidth() + .background(c.surface, RoundedCornerShape(IronLogRadius.lg.dp)) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + CompletionStat("Duration", formatDurationShort(durationSeconds)) + Box(Modifier.width(1.dp).height(36.dp).background(c.cardBorder)) + CompletionStat("Sets", totalSets.toString()) + Box(Modifier.width(1.dp).height(36.dp).background(c.cardBorder)) + CompletionStat("Volume", formatWeightFromKg(totalVolume, weightUnit)) + } + + // ── Fun comparison ────────────────────────────────────────────── + if (funComparison != null) { + Text( + "That's the weight of ${funComparison.text}!", + color = c.subtext, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.Medium, + ) + } + + // ── AI Session Debrief ────────────────────────────────────────── + if (cloudConfigured) { + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(c.accent.copy(alpha = 0.07f)) + .border(1.dp, c.accent.copy(alpha = 0.25f), RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + "AI SESSION DEBRIEF", + color = c.accent, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + if (debriefLoading) { + Column(Modifier.shimmer(), verticalArrangement = Arrangement.spacedBy(5.dp)) { + repeat(2) { idx -> + Box( + Modifier + .fillMaxWidth(if (idx == 1) 0.7f else 1f) + .height(13.dp) + .clip(RoundedCornerShape(4.dp)) + .background(c.faint) + ) + } + } + } else { + Text( + debriefText ?: "No evaluation available.", + color = c.text, + fontSize = IronLogType.body.fontSize.sp, + lineHeight = IronLogType.body.lineHeight.sp, + ) + } + } + } + + // ── Star rating ───────────────────────────────────────────────── + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "RATE THIS SESSION", + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + (1..5).forEach { star -> + Box( + Modifier + .size(44.dp) + .clickable { selectedRating = star }, + contentAlignment = Alignment.Center, + ) { + Text( + if (star <= selectedRating) "★" else "☆", + color = if (star <= selectedRating) c.gold else c.faint, + fontWeight = FontWeight(IronLogType.display.fontWeight), + fontSize = IronLogType.metric.fontSize.sp, + ) + } + } + } + } + + // ── Workout notes ──────────────────────────────────────────────── + OutlinedTextField( + value = workoutNotes, + onValueChange = { workoutNotes = it }, + placeholder = { Text("Add session notes (optional)…", color = c.muted, fontSize = IronLogType.body.fontSize.sp) }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 4, + ) + + // ── Primary CTA ───────────────────────────────────────────────── + Button( + onClick = { onComplete(selectedRating, workoutNotes.trim()) }, + colors = ButtonDefaults.buttonColors(containerColor = c.accent), + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(IronLogRadius.lg.dp), + ) { + Text( + "SAVE & FINISH", + color = c.textOnAccent, + fontWeight = FontWeight(IronLogType.button.fontWeight), + fontSize = IronLogType.section.fontSize.sp, + letterSpacing = IronLogType.button.letterSpacing.sp, + ) + } + + // ── Share link ────────────────────────────────────────────────── + Text( + "SHARE SUMMARY", + color = c.accent, + fontWeight = FontWeight(IronLogType.button.fontWeight), + fontSize = IronLogType.meta.fontSize.sp, + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + modifier = Modifier.clickable { + ShareService.shareMinimalCardImage( + context = context, + title = "Ironlog Workout", + headline = "Session Complete", + metrics = listOf( + ShareService.ShareMetric("Duration", formatDurationShort(durationSeconds)), + ShareService.ShareMetric("Sets", totalSets.toString()), + ShareService.ShareMetric("Volume", formatWeightFromKg(totalVolume, weightUnit)), + ShareService.ShareMetric("Rating", if (selectedRating > 0) "$selectedRating/5" else "-"), + ), + footnote = funComparison?.let { "Equivalent of ${it.text}" }, + ) + }, + ) + Spacer(Modifier.height(12.dp)) + } + } +} + +@Composable +private fun CompletionStat(label: String, value: String) { + val c = useTheme() + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + value, + color = c.text, + fontWeight = FontWeight(IronLogType.title.fontWeight), + fontSize = IronLogType.title.fontSize.sp, + ) + Text( + label, + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + ) + } +} + +@Composable +private fun ExerciseCard( + exIndex: Int, + exercise: NormalizedSessionExercise, + loggedSets: List, + input: Pair, + dispatch: (WorkoutAction) -> Unit, + baseExercisesCount: Int = 0, + onRemoveExercise: (() -> Unit)? = null, + onLogSet: (String, String) -> Unit, + weightUnit: String, + effortTracking: String, + hapticFeedback: Boolean, + onSetRpeChanged: (setIndex: Int, rpe: Double?) -> Unit, + onSetRirChanged: (setIndex: Int, rir: Int?) -> Unit, + onSetTypeChanged: (setIndex: Int, type: String) -> Unit, + onSetValuesChanged: (setIndex: Int, weight: Double?, reps: Double?) -> Unit, + onInsertWarmups: (warmups: List) -> Unit, + onSwapRequest: () -> Unit, + restSec: Int = 90, + onEditRest: () -> Unit = {}, + ghost: com.ironlog.app.ui.state.GhostData? = null, + exerciseNote: String = "", + supersetGroup: String? = null, + onSupersetChange: (String?) -> Unit = {}, + isDragging: Boolean = false, + dragHandleModifier: Modifier = Modifier, + activeProfile: GymProfileDto? = null, + settingsBarWeightKg: Double = 20.0, + targetOverride: com.ironlog.app.ui.state.TargetOverride? = null, // GAP-23 +) { + val c = useTheme() + val context = LocalContext.current + var weight by remember(exIndex, input.first) { mutableStateOf(input.first) } + var reps by remember(exIndex, input.second) { mutableStateOf(input.second) } + var noteExpanded by remember { mutableStateOf(exerciseNote.isNotBlank()) } + var localNote by remember(exerciseNote) { mutableStateOf(exerciseNote) } + val cardElevation = if (isDragging) 8.dp else 0.dp + + var showPlateModal by remember { mutableStateOf(false) } + var plateTarget by remember { mutableStateOf(0.0) } + var showCopyModal by remember { mutableStateOf(false) } + var showSupersetModal by remember { mutableStateOf(false) } + // GAP-23: target override dialog state + var showTargetOverrideDialog by remember { mutableStateOf(false) } + var overrideSetsInput by remember(targetOverride) { mutableStateOf(targetOverride?.sets?.toString() ?: exercise.sets.toString()) } + var overrideRepsInput by remember(targetOverride) { mutableStateOf(targetOverride?.reps?.toString() ?: exercise.reps.toString()) } + val supColor = when (supersetGroup) { + "A" -> Color(0xFFFF7043) + "B" -> Color(0xFF42A5F5) + "C" -> Color(0xFF66BB6A) + else -> c.accent + } + + val plateText = remember(weight, weightUnit, activeProfile) { + val w = weight.toDoubleOrNull() ?: 0.0 + val barWeight = activeProfile?.barWeightKg ?: settingsBarWeightKg + if (w > barWeight && supportsPlateBreakdown(exercise)) getPlateText(w, barWeight, activeProfile, weightUnit = weightUnit) else null + } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + ) { + if (!supersetGroup.isNullOrBlank()) { + Box( + Modifier + .width(4.dp) + .fillMaxHeight() + .clip(RoundedCornerShape(2.dp)) + .background(supColor), + ) + Spacer(Modifier.width(6.dp)) + } + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, if (isDragging) c.accent.copy(alpha = 0.5f) else c.cardBorder), + shape = RoundedCornerShape(IronLogRadius.lg.dp), + elevation = CardDefaults.cardElevation(defaultElevation = cardElevation), + modifier = Modifier.weight(1f), + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Outlined.DragHandle, + contentDescription = "Drag", + tint = c.muted, + modifier = dragHandleModifier.size(20.dp).padding(end = 0.dp), + ) + Spacer(Modifier.width(8.dp)) + Column( + Modifier + .weight(1f) + .combinedClickable( + onClick = {}, + onLongClick = { showSupersetModal = true }, + ), + ) { + Text( + exercise.name, + color = c.text, + fontWeight = FontWeight(IronLogType.section.fontWeight), + fontSize = IronLogType.section.fontSize.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // GAP-23: show override badge or normal target + if (targetOverride != null) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "${targetOverride.sets} × ${targetOverride.reps} · ${formatTrackingType(exercise.trackingType)}", + color = c.warning, + fontSize = IronLogType.meta.fontSize.sp, + fontWeight = FontWeight.Medium, + ) + Box( + Modifier.clip(RoundedCornerShape(IronLogRadius.full.dp)).background(c.warning.copy(alpha = 0.15f)).padding(horizontal = 4.dp, vertical = 1.dp) + ) { + Text("CUSTOM", color = c.warning, fontSize = (IronLogType.meta.fontSize - 2).sp, fontWeight = FontWeight.Bold) + } + } + } else { + Text( + "${exercise.sets} × ${exercise.reps} · ${formatTrackingType(exercise.trackingType)}", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + if (!supersetGroup.isNullOrBlank()) { + Box( + Modifier + .padding(top = 4.dp) + .clip(RoundedCornerShape(IronLogRadius.full.dp)) + .background(supColor.copy(alpha = 0.2f)) + .border(1.dp, supColor, RoundedCornerShape(IronLogRadius.full.dp)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + "SUPERSET $supersetGroup", + color = supColor, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.6.sp, + ) + } + } + } + var showExerciseMenu by remember { mutableStateOf(false) } + Box { + androidx.compose.material3.IconButton(onClick = { showExerciseMenu = true }) { + Icon(androidx.compose.material.icons.Icons.Filled.MoreVert, contentDescription = "Menu", tint = c.muted) + } + androidx.compose.material3.DropdownMenu(showExerciseMenu, onDismissRequest = { showExerciseMenu = false }) { + androidx.compose.material3.DropdownMenuItem( + text = { Text("Swap Exercise", color = c.text) }, + onClick = { showExerciseMenu = false; onSwapRequest() } + ) + // GAP-23: Change target for this session + androidx.compose.material3.DropdownMenuItem( + text = { Text("Change target", color = c.text) }, + onClick = { + showExerciseMenu = false + overrideSetsInput = targetOverride?.sets?.toString() ?: exercise.sets.toString() + overrideRepsInput = targetOverride?.reps?.toString() ?: exercise.reps.toString() + showTargetOverrideDialog = true + } + ) + androidx.compose.material3.DropdownMenuItem( + text = { Text("Watch on YouTube", color = c.text) }, + onClick = { + showExerciseMenu = false + val query = Uri.encode(exercise.name) + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://www.youtube.com/results?search_query=$query+exercise+tutorial")) + context.startActivity(intent) + }, + ) + androidx.compose.material3.DropdownMenuItem( + text = { Text("Remove Exercise", color = c.danger) }, + onClick = { + showExerciseMenu = false + onRemoveExercise?.invoke() + ?: dispatch(WorkoutAction.RemoveExercise(exIndex, baseExercisesCount)) + }, + ) + } + } + } + loggedSets.forEachIndexed { setIndex, set -> + SetRow( + set = set, + setIndex = setIndex, + exIndex = exIndex, + dispatch = { action -> + dispatch(action) + when (action) { + is WorkoutAction.SetRpe -> onSetRpeChanged(setIndex, action.rpe) + is WorkoutAction.SetRir -> onSetRirChanged(setIndex, action.rir) + is WorkoutAction.SetType -> onSetTypeChanged(setIndex, action.type) + is WorkoutAction.UpdateSet -> onSetValuesChanged(setIndex, action.weight, action.reps) + else -> Unit + } + }, + effortTracking = effortTracking, + hapticFeedback = hapticFeedback, + weightUnit = weightUnit, + trackingType = exercise.trackingType, + ) + } + if (!exercise.trackingType.startsWith("duration")) { + val hasWarmups = loggedSets.any { it.type == "warmup" } + val topWorking = (weight.toDoubleOrNull() + ?: loggedSets.lastOrNull { it.type != "warmup" }?.weight + ?: 0.0) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .clickable(enabled = !hasWarmups && topWorking > 40.0) { + val warmups = buildWarmupSets(topWorking, activeProfile?.barWeightKg ?: settingsBarWeightKg) + if (warmups.isNotEmpty()) { + dispatch(WorkoutAction.InsertWarmups(exIndex, warmups)) + onInsertWarmups(warmups) + } + } + .padding(vertical = 2.dp), + ) { + Icon(Icons.Outlined.AddCircleOutline, contentDescription = null, tint = c.muted, modifier = Modifier.size(14.dp)) + Text( + if (hasWarmups) "Warmups inserted" else "+ Insert warmups", + color = if (hasWarmups || topWorking <= 40.0) c.faint else c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + weight, + { weight = it; dispatch(WorkoutAction.SetInput(exIndex, weight = it)) }, + label = { Text(weightUnit.uppercase()) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + reps, + { reps = it; dispatch(WorkoutAction.SetInput(exIndex, reps = it)) }, + label = { Text(if (exercise.trackingType.startsWith("duration")) "SECONDS" else "REPS") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + Button( + onClick = { + if (hapticFeedback) HapticsEngine.mediumStrong(context) + onLogSet(weight, reps) + }, + colors = ButtonDefaults.buttonColors(containerColor = c.accent), + ) { Text("LOG", color = c.textOnAccent, fontWeight = FontWeight(IronLogType.button.fontWeight)) } + } + if (plateText != null) { + Text( + "PLATES: $plateText", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + modifier = Modifier.clickable { + plateTarget = weight.toDoubleOrNull() ?: 0.0 + showPlateModal = true + } + ) + } + if (ghost != null && ghost.sets.isNotEmpty()) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + ) { + Text( + "LAST SESSION:", + color = c.muted, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = FontWeight(IronLogType.micro.fontWeight), + letterSpacing = IronLogType.micro.letterSpacing.sp, + ) + Text( + ghost.sets.take(3).joinToString(" ") { s -> + if (s.weight > 0) "${s.weight.toInt()} × ${s.reps.toInt()}" + else "BW × ${s.reps.toInt()}" + }, + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + "COPY", + color = c.accent, + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 0.5.sp, + modifier = Modifier.clickable { showCopyModal = true }.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + // Progression suggestion based on last session + val suggestion = buildProgressionSuggestion(ghost, weightUnit) + if (suggestion != null) { + Text( + "↑ $suggestion", + color = c.success.copy(alpha = 0.85f), + fontSize = IronLogType.micro.fontSize.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + // Exercise note — tap to expand/add + if (noteExpanded) { + OutlinedTextField( + value = localNote, + onValueChange = { localNote = it; dispatch(WorkoutAction.SetExerciseNote(exIndex, it)) }, + label = { Text("Exercise note") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3, + ) + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.clickable { noteExpanded = true }.padding(top = 2.dp), + ) { + Icon(Icons.Outlined.NoteAlt, contentDescription = null, tint = c.muted, modifier = Modifier.size(14.dp)) + Text( + if (localNote.isBlank()) "Add note" else localNote, + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + // Rest timer display — tappable to override + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.clickable { onEditRest() }.padding(top = 2.dp), + ) { + Icon(Icons.Outlined.Timer, contentDescription = null, tint = c.muted, modifier = Modifier.size(14.dp)) + Text( + "${restSec}s rest · tap to edit", + color = c.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + } + } + } + + if (showPlateModal) { + PlateModal( + targetKg = plateTarget, + activeProfile = activeProfile, + settingsBarWeightKg = settingsBarWeightKg, + weightUnit = weightUnit, + onClose = { showPlateModal = false } + ) + } + if (showCopyModal && ghost != null && ghost.sets.isNotEmpty()) { + CopyPreviousModal( + ghostSets = ghost.sets, + weightUnit = weightUnit, + onCopy = { ghostSet -> + weight = if (ghostSet.weight > 0) ghostSet.weight.roundToInt().toString() else "" + reps = ghostSet.reps.roundToInt().toString() + dispatch(WorkoutAction.SetInput(exIndex, weight = weight, reps = reps)) + showCopyModal = false + }, + onDismiss = { showCopyModal = false }, + ) + } + if (showSupersetModal) { + SupersetModal( + selected = supersetGroup, + onSelect = { + onSupersetChange(it) + showSupersetModal = false + }, + onDismiss = { showSupersetModal = false }, + ) + } + + // GAP-23: Change target dialog + if (showTargetOverrideDialog) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { showTargetOverrideDialog = false }, + title = { Text("Change Target", color = c.text) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Override sets × reps for this session only.", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = overrideSetsInput, + onValueChange = { overrideSetsInput = it.filter { ch -> ch.isDigit() } }, + label = { Text("Sets", color = c.muted) }, + singleLine = true, + modifier = Modifier.weight(1f), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(focusedBorderColor = c.accent, unfocusedBorderColor = c.cardBorder, focusedTextColor = c.text, unfocusedTextColor = c.text), + ) + OutlinedTextField( + value = overrideRepsInput, + onValueChange = { overrideRepsInput = it.filter { ch -> ch.isDigit() } }, + label = { Text("Reps", color = c.muted) }, + singleLine = true, + modifier = Modifier.weight(1f), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(focusedBorderColor = c.accent, unfocusedBorderColor = c.cardBorder, focusedTextColor = c.text, unfocusedTextColor = c.text), + ) + } + } + }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = { + val s = overrideSetsInput.toIntOrNull()?.coerceIn(1, 20) ?: return@TextButton + val r = overrideRepsInput.toIntOrNull()?.coerceIn(1, 50) ?: return@TextButton + dispatch(WorkoutAction.OverrideTarget(exIndex, s, r)) + showTargetOverrideDialog = false + }) { + Text("APPLY", color = c.accent, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = { showTargetOverrideDialog = false }) { + Text("CANCEL", color = c.muted) + } + }, + containerColor = c.card, + ) + } +} + +private fun buildWarmupSets(targetWeight: Double, barWeightKg: Double): List { + return TrainingIntelligenceEngine.generateWarmupSets(targetWeight, barWeightKg).map { (w, r) -> + LoggedSet( + weight = w, + reps = r.toDouble(), + type = "warmup", + trackingType = "weight_reps", + ) + } +} + +private fun Double.roundToNearest(step: Double): Double = if (step <= 0.0) this else (round(this / step) * step).coerceAtLeast(step) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CopyPreviousModal( + ghostSets: List, + weightUnit: String, + onCopy: (com.ironlog.app.ui.state.GhostSet) -> Unit, + onDismiss: () -> Unit, +) { + val c = useTheme() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = c.card) { + Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + "Copy Previous", + color = c.text, + fontWeight = FontWeight(IronLogType.title.fontWeight), + fontSize = IronLogType.title.fontSize.sp, + ) + Text( + "Tap a set to fill in the weight and reps fields.", + color = c.muted, + fontSize = IronLogType.body.fontSize.sp, + ) + ghostSets.forEachIndexed { idx, gs -> + val wStr = if (gs.weight > 0) formatWeightFromKg(gs.weight, weightUnit) else "BW" + val rStr = "${gs.reps.roundToInt()} reps" + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.md.dp)) + .background(c.surface) + .border(1.dp, c.faint, RoundedCornerShape(IronLogRadius.md.dp)) + .clickable { onCopy(gs) } + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Set ${idx + 1}", color = c.muted, fontSize = IronLogType.meta.fontSize.sp) + Text("$wStr × $rStr", color = c.text, fontWeight = FontWeight.SemiBold, fontSize = IronLogType.body.fontSize.sp) + Text("USE →", color = c.accent, fontSize = IronLogType.micro.fontSize.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.5.sp) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SupersetModal( + selected: String?, + onSelect: (String?) -> Unit, + onDismiss: () -> Unit, +) { + val c = useTheme() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val options = listOf("" to "No superset", "A" to "Group A", "B" to "Group B", "C" to "Group C") + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = c.card) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Superset", color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.title.fontSize.sp) + options.forEach { (value, label) -> + val isActive = (selected ?: "") == value + val color = when (value) { + "A" -> Color(0xFFFF7043) + "B" -> Color(0xFF42A5F5) + "C" -> Color(0xFF66BB6A) + else -> c.muted + } + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.md.dp)) + .background(if (isActive) color.copy(alpha = 0.18f) else c.surface) + .border(1.dp, if (isActive) color else c.cardBorder, RoundedCornerShape(IronLogRadius.md.dp)) + .clickable { onSelect(value.ifBlank { null }) } + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, color = if (isActive) color else c.text, fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal) + if (isActive) Text("✓", color = color, fontWeight = FontWeight.Bold) + } + } + Spacer(Modifier.height(12.dp)) + } + } +} + +@Composable +private fun RestTimerPanel( + restTimer: com.ironlog.app.ui.state.RestTimerState, + modifier: Modifier, + dispatch: (WorkoutAction) -> Unit, + onRestCleared: () -> Unit, + hapticFeedback: Boolean, +) { + val c = useTheme() + val context = LocalContext.current + if (!restTimer.active || restTimer.endTime == null) return + var remaining by remember(restTimer.endTime, restTimer.paused) { mutableStateOf(((restTimer.endTime - System.currentTimeMillis()) / 1000).coerceAtLeast(0).toInt()) } + var lastHapticSecond by remember(restTimer.endTime) { mutableStateOf(Int.MIN_VALUE) } + LaunchedEffect(restTimer.endTime, restTimer.paused) { + while (restTimer.active && !restTimer.paused) { + remaining = ((restTimer.endTime - System.currentTimeMillis()) / 1000).coerceAtLeast(0).toInt() + if (hapticFeedback && remaining != lastHapticSecond) { + // Final 5-second countdown (existing behaviour) — increasingly intense. + when (remaining) { + 5 -> { HapticsEngine.strong(context); lastHapticSecond = remaining } + 4 -> { HapticsEngine.mediumStrong(context); lastHapticSecond = remaining } + 3 -> { HapticsEngine.medium(context); lastHapticSecond = remaining } + 2 -> { HapticsEngine.low(context); lastHapticSecond = remaining } + 1 -> { HapticsEngine.strong(context); lastHapticSecond = remaining } + else -> { + // 30-second milestone pulse — fires at 30s, 60s, 90s, ... remaining. + // Skip the very start (remaining == total) so it doesn't fire instantly. + if (remaining in 6..(restTimer.total - 1) && remaining % 30 == 0) { + HapticsEngine.medium(context) + lastHapticSecond = remaining + } + } + } + } + if (remaining <= 0) { dispatch(WorkoutAction.SkipRest); onRestCleared(); break } + delay(500) + } + } + Row( + modifier + .fillMaxWidth() + .background(c.card, RoundedCornerShape(IronLogRadius.lg.dp)) + .border(1.dp, c.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + "REST", + color = c.muted, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + fontSize = IronLogType.eyebrow.fontSize.sp, + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + RollingTimerText( + value = formatDurationShort(remaining), + color = c.text, + fontWeight = FontWeight(IronLogType.display.fontWeight), + fontSizeSp = IronLogType.title.fontSize, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "+30s", + color = c.text, + modifier = Modifier.clickable { + if (hapticFeedback) HapticsEngine.selection(context) + dispatch(WorkoutAction.Add30s) + }.padding(8.dp), + ) + Text( + if (restTimer.paused) "RESUME" else "PAUSE", + color = c.text, + modifier = Modifier.clickable { + if (hapticFeedback) HapticsEngine.lightConfirm(context) + if (restTimer.paused) { + val pausedAt = restTimer.pausedAt ?: System.currentTimeMillis() + val pausedMs = System.currentTimeMillis() - pausedAt + dispatch(WorkoutAction.ResumeRest((restTimer.endTime ?: System.currentTimeMillis()) + pausedMs)) + } else dispatch(WorkoutAction.PauseRest(System.currentTimeMillis())) + }.padding(8.dp), + ) + Text( + "SKIP", + color = c.accent, + fontWeight = FontWeight(IronLogType.button.fontWeight), + modifier = Modifier.clickable { + if (hapticFeedback) HapticsEngine.mediumStrong(context) + dispatch(WorkoutAction.SkipRest); onRestCleared() + }.padding(8.dp), + ) + } + } +} + +fun toTitleCase(value: String?): String = value.orEmpty().replace(Regex("[_-]+"), " ").trim().split(Regex("\\s+")).filter { it.isNotBlank() }.joinToString(" ") { it.lowercase().replaceFirstChar { ch -> ch.titlecase() } } +fun parseRepTarget(value: Any?, fallback: Int = 8): Int = when (value) { is Number -> value.toInt(); else -> Regex("\\d+").find(value?.toString().orEmpty())?.value?.toIntOrNull() ?: fallback } +fun normalizeExerciseLookupKey(value: String?): String = value.orEmpty().lowercase().replace(Regex("[^a-z0-9]+"), " ").trim() +fun isTimeBasedExercise(trackingType: String?): Boolean = trackingType.orEmpty().startsWith("duration") + +private fun formatTrackingType(trackingType: String): String = when (trackingType.lowercase().trim()) { + "weight_reps" -> "Weight × Reps" + "bodyweight_reps" -> "Bodyweight" + "bodyweight_plus_weight_reps", + "weighted_bodyweight" -> "Bodyweight + Weight" + "assisted_bodyweight" -> "Assisted Bodyweight" + "reps_only" -> "Reps Only" + "duration" -> "Duration" + "duration_weight" -> "Duration + Weight" + "cardio" -> "Cardio" + else -> trackingType.replace("_", " ").split(" ") + .joinToString(" ") { it.replaceFirstChar { c -> c.titlecase() } } +} +fun isBodyweightExercise(exercise: LegacyExerciseShape): Boolean = exercise.isBodyweight || exercise.equipment.lowercase().contains("bodyweight") || Regex("pull.?up|chin.?up|push.?up|\\bdip\\b|plank|crunch|sit.?up|leg raise|mountain climber|muscle.?up|handstand|pistol|nordic", RegexOption.IGNORE_CASE).containsMatchIn(exercise.name) +fun supportsPlateBreakdown(exercise: NormalizedSessionExercise): Boolean = exercise.equipment.equals("Barbell", ignoreCase = true) || exercise.name.contains("barbell", ignoreCase = true) +fun getFunComparison(totalKg: Double): FunComparison = FUN_COMPARISONS.lastOrNull { totalKg >= it.threshold } ?: FUN_COMPARISONS.first() +fun getPlateText(targetKg: Double, barWeight: Double, profile: GymProfileDto?, weightUnit: String = "kg"): String { + val inventory = profile?.plates?.takeIf { it.isNotEmpty() } ?: DEFAULT_PLATES + val result = calculatePlates(targetKg, barWeight, inventory) + if (result.platesPerSide.isEmpty()) return "Bar only" + return result.platesPerSide.flatMap { p -> List(p.quantity) { p.weightKg } } + .joinToString(" + ") { formatWeightFromKg(it, weightUnit) } + " each side" +} + +private fun isHeavyCompoundExercise(name: String): Boolean { + val n = name.lowercase() + return listOf( + "squat", + "deadlift", + "bench press", + "overhead press", + "romanian deadlift", + "barbell row", + "hip thrust", + "weighted pull", + "leg press", + ).any { n.contains(it) } +} + +@Composable +private fun RollingTimerText( + value: String, + color: Color, + fontWeight: FontWeight, + fontSizeSp: Int, +) { + Row(horizontalArrangement = Arrangement.spacedBy(0.dp), verticalAlignment = Alignment.CenterVertically) { + value.forEachIndexed { idx, ch -> + AnimatedContent( + targetState = ch, + transitionSpec = { + slideInVertically { full -> full } togetherWith slideOutVertically { full -> -full } + }, + label = "timer_digit_$idx", + ) { animated -> + Text( + animated.toString(), + color = color, + fontWeight = fontWeight, + fontSize = fontSizeSp.sp, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlateModal( + targetKg: Double, + activeProfile: GymProfileDto?, + settingsBarWeightKg: Double, + weightUnit: String, + onClose: () -> Unit +) { + val c = useTheme() + val barWeight = activeProfile?.barWeightKg ?: settingsBarWeightKg + val inventory = activeProfile?.plates?.takeIf { it.isNotEmpty() } ?: DEFAULT_PLATES + val result = remember(targetKg, barWeight, inventory) { calculatePlates(targetKg, barWeight, inventory) } + + ModalBottomSheet( + onDismissRequest = onClose, + containerColor = c.bg, + dragHandle = null + ) { + Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + // FIXED: 1 + Text("PLATE CALCULATOR", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = IronLogType.eyebrow.letterSpacing.sp) + Text( + "Load ${formatWeightFromKg(targetKg, weightUnit)}", + color = c.text, + fontSize = IronLogType.title.fontSize.sp, + fontWeight = FontWeight.Black + ) + + if (!result.isValid) { + Text( + "Cannot exactly load ${formatWeightFromKg(targetKg, weightUnit)} with available plates.", + color = c.danger, + fontSize = IronLogType.body.fontSize.sp + ) + } + + // GAP-11: Visual barbell diagram + if (result.platesPerSide.isNotEmpty()) { + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("BARBELL VIEW", color = c.muted, fontSize = IronLogType.micro.fontSize.sp, letterSpacing = 2.sp) + BarbellDiagram( + platesPerSide = result.platesPerSide, // List + inventory = inventory, + modifier = Modifier.fillMaxWidth().height(80.dp), + ) + } + } + } + + Card( + colors = CardDefaults.cardColors(containerColor = c.card), + border = androidx.compose.foundation.BorderStroke(1.dp, c.cardBorder), + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("BAR (${formatWeightFromKg(barWeight, weightUnit)})", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = IronLogType.eyebrow.letterSpacing.sp) + + if (result.platesPerSide.isEmpty()) { + Text("Bar only", color = c.text, fontSize = IronLogType.section.fontSize.sp, fontWeight = FontWeight.Bold) + } else { + result.platesPerSide.forEach { p -> + val assignedHex = inventory.firstOrNull { kotlin.math.abs(it.weightKg - p.weightKg) < 0.001 }?.color.orEmpty() + val defaultPlateColor = when { + p.weightKg >= 20 -> Color(0xFFD32F2F) + p.weightKg >= 15 -> Color(0xFF1976D2) + p.weightKg >= 10 -> Color(0xFFFFB300) + p.weightKg >= 5 -> Color(0xFF43A047) + else -> Color(0xFF8E24AA) + } + val plateColor = if (assignedHex.isNotBlank()) { + try { Color(android.graphics.Color.parseColor(assignedHex)) } catch (_: Exception) { defaultPlateColor } + } else defaultPlateColor + Column(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "${formatWeightFromKg(p.weightKg, weightUnit)} plate", + color = c.text, + fontSize = IronLogType.section.fontSize.sp, + fontWeight = FontWeight.Bold + ) + Text("x${p.quantity} per side", color = c.accent, fontSize = IronLogType.body.fontSize.sp, fontWeight = FontWeight.Bold) + } + Box( + Modifier + .width((8 + p.weightKg * 3).dp) + .height(24.dp) + .clip(RoundedCornerShape(6.dp)) + .background(plateColor.copy(alpha = 0.85f)) + ) + Text( + "per side visual", + color = c.muted, + fontSize = IronLogType.micro.fontSize.sp, + ) + } + } + } + } + } + + Button( + onClick = onClose, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + colors = ButtonDefaults.buttonColors(containerColor = c.accent) + ) { + Text("DONE", color = c.bg, fontWeight = FontWeight.Black, letterSpacing = 2.sp) + } + Spacer(Modifier.height(40.dp)) + } + } +} + +@Composable +private fun ConfettiOverlay( + modifier: Modifier = Modifier, + burstId: Int, + accent: Color, +) { + val colors: List = remember(accent) { + listOf( + accent.toArgb(), + Color(0xFFFFC857).toArgb(), + Color(0xFF3DDC97).toArgb(), + Color(0xFF64B5F6).toArgb(), + Color(0xFFFF8A65).toArgb(), + Color(0xFFE1BEE7).toArgb(), + Color.White.toArgb(), + ) + } + val parties = remember(burstId, colors) { + val sizes = listOf( + Size(sizeInDp = 5, mass = 1.6f, massVariance = 0.35f), + Size(sizeInDp = 7, mass = 2.3f, massVariance = 0.4f), + Size(sizeInDp = 10, mass = 3.0f, massVariance = 0.45f), + ) + val shapes = listOf( + Shape.Circle, + Shape.Square, + Shape.Rectangle(0.48f), + ) + val rotation = Rotation( + enabled = true, + speed = 9.5f, + variance = 0.75f, + multiplier2D = 1.0f, + multiplier3D = 0.55f, + ) + listOf( + Party( + speed = 12f, + maxSpeed = 26f, + damping = 0.88f, + angle = 270, + spread = 54, + size = sizes, + colors = colors, + shapes = shapes, + emitter = Emitter(duration = 260.milliseconds).max(90), + position = Position.Relative(0.5, 0.72), + timeToLive = 2600L, + fadeOutEnabled = true, + rotation = rotation, + ), + Party( + speed = 13f, + maxSpeed = 29f, + damping = 0.87f, + angle = 315, + spread = 42, + size = sizes, + colors = colors, + shapes = shapes, + emitter = Emitter(duration = 220.milliseconds).max(55), + position = Position.Relative(0.10, 0.84), + timeToLive = 2500L, + fadeOutEnabled = true, + rotation = rotation, + ), + Party( + speed = 13f, + maxSpeed = 29f, + damping = 0.87f, + angle = 225, + spread = 42, + size = sizes, + colors = colors, + shapes = shapes, + emitter = Emitter(duration = 220.milliseconds).max(55), + position = Position.Relative(0.90, 0.84), + timeToLive = 2500L, + fadeOutEnabled = true, + rotation = rotation, + ), + ) + } + + ConfettiKit( + modifier = modifier, + parties = parties, + ) +} + +private fun normalizeSessionExercise(planEx: UiPlanExercise, lib: LegacyExerciseShape?): NormalizedSessionExercise { + val trackingType = lib?.trackingType ?: "weight_reps" + return NormalizedSessionExercise( + name = lib?.name ?: planEx.name.ifBlank { "Custom Exercise" }, + exerciseId = planEx.exerciseId.ifBlank { lib?.id ?: planEx.name }, + sets = planEx.sets.takeIf { it > 0 } ?: 3, + reps = parseRepTarget(planEx.reps, if (trackingType.startsWith("duration")) 60 else 8), + trackingType = trackingType, + isWarmup = planEx.isWarmup, + equipment = lib?.equipment, + ) +} + +@Serializable +private data class WorkoutDraftDto( + val inputs: Map = emptyMap(), + val setLog: Map> = emptyMap(), + val exerciseNotes: Map = emptyMap(), + val supersetGroups: Map = emptyMap(), + val restTimer: RestTimerDto = RestTimerDto(), + val addedExercises: List = emptyList(), + val removedBaseExerciseIndices: List = emptyList(), + val orderedIndices: List = emptyList(), + /** Persists mid-workout exercise swaps so they survive minimize/resume. Key = exIndex (String). */ + val swappedExercises: Map = emptyMap(), +) + +/** Minimal snapshot of a swapped exercise — enough to rebuild the UI overlay after resume. */ +@Serializable +private data class SwappedExerciseDto( + val id: String = "", + val name: String = "", + val trackingType: String = "weight_reps", + val equipment: String = "", +) + +@Serializable +private data class WorkoutInputDto(val weight: String = "", val reps: String = "") + +@Serializable +private data class LoggedSetDto( + val id: String = "", + val weight: Double = 0.0, + val reps: Double = 0.0, + val type: String = "normal", + val rpe: Double? = null, + val rir: Int? = null, + val note: String? = null, + val orm: Double = 0.0, + val trackingType: String = "weight_reps", + val durationSec: Double? = null, +) + +@Serializable +private data class RestTimerDto( + val active: Boolean = false, + val endTime: Long? = null, + val total: Int = 0, + val paused: Boolean = false, + val pausedAt: Long? = null, + val triggerExIndex: Int? = null, +) + +/** + * Builds a short progression suggestion string based on the last session's ghost data. + * Returns null if there's not enough data to make a suggestion. + * + * Logic: + * - If ghost sets have weight > 0: suggest the same reps at +2.5kg (or +5lb). + * - If bodyweight only: suggest +2 reps on the top set. + */ +private fun buildProgressionSuggestion( + ghost: com.ironlog.app.ui.state.GhostData, + weightUnit: String, +): String? { + val sets = ghost.sets.ifEmpty { return null } + val isLb = weightUnit.lowercase().trimEnd('s') == "lb" + val increment = if (isLb) 5.0 else 2.5 + val topSet = sets.maxByOrNull { it.weight } + return if (topSet != null && topSet.weight > 0) { + val suggested = topSet.weight + increment + val repStr = if (topSet.reps > 0) " × ${topSet.reps.toInt()}" else "" + java.lang.String.format(java.util.Locale.US, "%.1f $weightUnit$repStr", suggested) + } else { + // Bodyweight: suggest +2 reps on top set + val topReps = sets.maxOfOrNull { it.reps }?.toInt() ?: return null + "BW × ${topReps + 2} reps" + } +} + +// ── GAP-11: Barbell diagram ─────────────────────────────────────────────────── + +/** International-standard plate fill color, overridden by profile hex if set. */ +private fun plateFillColor(weightKg: Double, hexOverride: String): Color { + if (hexOverride.isNotBlank()) { + runCatching { return Color(android.graphics.Color.parseColor(hexOverride)) } + } + return when { + weightKg >= 25.0 -> Color(0xFFD32F2F) // red + weightKg >= 20.0 -> Color(0xFF1565C0) // blue + weightKg >= 15.0 -> Color(0xFFF9A825) // yellow + weightKg >= 10.0 -> Color(0xFF2E7D32) // green + weightKg >= 5.0 -> Color(0xFFF5F5F5) // white / light + weightKg >= 2.5 -> Color(0xFF212121) // black + else -> Color(0xFFBDBDBD) // chrome + } +} + +/** + * Canvas barbell diagram: bar + sleeve + plates stacked from center outward on both sides. + * Heaviest plates are closest to the sleeve; lighter plates at the ends. + * Plate height scales with weight so heavier plates are visually taller. + */ +@Composable +private fun BarbellDiagram( + platesPerSide: List, // heaviest first (from calculatePlates / PlateCalculationResult) + inventory: List, + modifier: Modifier = Modifier, +) { + val c = useTheme() + val barColor = c.subtext.copy(alpha = 0.7f) + val sleeveColor = c.subtext + + // Flatten plate list: each PlateDto(wkg, qty) → qty individual plates + val flatPlates: List> = platesPerSide.flatMap { pd -> + val hex = inventory.firstOrNull { kotlin.math.abs(it.weightKg - pd.weightKg) < 0.001 }?.color.orEmpty() + val col = plateFillColor(pd.weightKg, hex) + List(pd.quantity) { pd.weightKg to col } + } + + Canvas(modifier = modifier) { + val cx = size.width / 2f + val cy = size.height / 2f + val barH = 8f + val sleeveW = 72f + val sleeveH = 24f + val collarW = 12f + val collarH = 30f + val plateW = 13f + + // Bar (full width) + drawRect(barColor, topLeft = Offset(0f, cy - barH / 2f), size = androidx.compose.ui.geometry.Size(size.width, barH)) + + // Sleeve (center, thicker) + drawRect(sleeveColor, topLeft = Offset(cx - sleeveW / 2f, cy - sleeveH / 2f), size = androidx.compose.ui.geometry.Size(sleeveW, sleeveH)) + + // Plates — right side (stack outward from sleeve) + var rightX = cx + sleeveW / 2f + collarW + flatPlates.forEach { (wkg, col) -> + val pH = (wkg.toFloat() * 2.4f).coerceIn(18f, 62f) + drawRect(col, topLeft = Offset(rightX, cy - pH / 2f), size = androidx.compose.ui.geometry.Size(plateW - 1f, pH)) + drawRect(col.copy(alpha = 0.35f), topLeft = Offset(rightX, cy - pH / 2f), size = androidx.compose.ui.geometry.Size(plateW - 1f, pH), style = Stroke(1f)) + rightX += plateW + } + + // Plates — left side (mirror; heaviest still closest to sleeve) + var leftX = cx - sleeveW / 2f - collarW + flatPlates.forEach { (wkg, col) -> + val pH = (wkg.toFloat() * 2.4f).coerceIn(18f, 62f) + leftX -= plateW + drawRect(col, topLeft = Offset(leftX + 1f, cy - pH / 2f), size = androidx.compose.ui.geometry.Size(plateW - 1f, pH)) + } + + // Collars (drawn on top of plates so they're always visible) + val collarColor = barColor.copy(alpha = 0.9f) + drawRect(collarColor, topLeft = Offset(cx + sleeveW / 2f, cy - collarH / 2f), size = androidx.compose.ui.geometry.Size(collarW, collarH)) + drawRect(collarColor, topLeft = Offset(cx - sleeveW / 2f - collarW, cy - collarH / 2f), size = androidx.compose.ui.geometry.Size(collarW, collarH)) + } +} + +@Serializable +private data class AddedExerciseDto( + val exerciseId: String = "", + val name: String = "", + val trackingType: String = "weight_reps", + val equipment: String? = null, + val sets: Int = 3, + val reps: Int = 8, +) + +@Composable +private fun ActiveWorkoutRollingTimerText(vm: ActiveWorkoutViewModel, c: IronLogThemeTokens) { + val elapsedSeconds by vm.elapsedSeconds.collectAsState() + val timerStarted by vm.timerStarted.collectAsState() + RollingTimerText( + value = if (timerStarted) formatDurationShort(elapsedSeconds) else "--:--", + color = c.accent, + fontWeight = FontWeight.Bold, + fontSizeSp = IronLogType.body.fontSize, + ) +} diff --git a/app/src/main/java/com/ironlog/app/ui/screens/workout/WorkoutCalendarScreen.kt b/app/src/main/java/com/ironlog/app/ui/screens/workout/WorkoutCalendarScreen.kt new file mode 100644 index 0000000..b1f0594 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/workout/WorkoutCalendarScreen.kt @@ -0,0 +1,655 @@ +package com.ironlog.app.ui.screens.workout + +import com.ironlog.app.domain.gamification.parseHistoryInstant +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.ironlog.app.data.model.CreateCompletedWorkoutInput +import com.ironlog.app.ui.components.ScreenHeader +import com.ironlog.app.ui.context.useTheme +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.theme.IronLogRadius +import com.ironlog.app.ui.theme.IronLogType +import com.ironlog.app.util.formatWeightFromKg +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import kotlin.math.roundToInt + +// GAP-19: ISO week starts Monday to match the rest of the app +private val DAY_HEADERS = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") +private val MONTH_NAMES = listOf("January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December") + +fun getCalendarStreakCount(history: List): Int { + if (history.isEmpty()) return 0 + val workoutDays = history.map { it.date.substringBefore('T') }.toSet() + val cursor = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) } + var streak = 0 + val fmt = SimpleDateFormat("yyyy-MM-dd", Locale.US) + while (true) { + val key = fmt.format(cursor.time) + if (workoutDays.contains(key)) { streak++; cursor.add(Calendar.DATE, -1) } else break + } + return streak +} + +fun formatDurationMins(seconds: Int?): String { + if (seconds == null || seconds == 0) return "0min" + val m = (seconds / 60.0).roundToInt() + if (m < 60) return "${m}min" + val h = m / 60 + val rem = m % 60 + return if (rem > 0) "${h}h ${rem}min" else "${h}h" +} + +fun formatDateLong(isoString: String): String = runCatching { + val instant = java.time.Instant.parse(isoString) + java.time.format.DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy", Locale.US) + .format(instant.atZone(java.time.ZoneId.systemDefault())) +}.getOrElse { isoString } + +fun calcSessionVolume(session: HistoryEntry): Int = session.exercises.sumOf { ex -> + ex.sets.sumOf { set -> ((set.weight ?: 0.0) * (set.reps ?: 0.0)).roundToInt() } +} + +fun toDisplayVolume(kgValue: Int, unit: String): Int = if (unit == "lbs") (kgValue * 2.2046226218).roundToInt() else kgValue + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun WorkoutCalendarScreen( + history: List, + weightUnit: String = "kg", + onBack: () -> Unit = {}, + onLogWorkout: (CreateCompletedWorkoutInput) -> Unit = {}, + onStartWorkout: (dateKey: String) -> Unit = {}, +) { + val colors = useTheme() + val today = remember { Calendar.getInstance() } + var viewYear by remember { mutableIntStateOf(today.get(Calendar.YEAR)) } + var viewMonth by remember { mutableIntStateOf(today.get(Calendar.MONTH)) } + var selectedSession by remember { mutableStateOf(null) } + // Week view state — weekOffset 0 = current week, -1 = last week, etc. + var showWeekView by remember { mutableStateOf(false) } + var weekOffset by remember { mutableIntStateOf(0) } + + var addForDate by remember { mutableStateOf(null) } + + val sessionsByDate = remember(history) { history.groupBy { it.date.substringBefore('T') } } + val streak = remember(history) { getCalendarStreakCount(history) } + val monthSessions = remember(history, viewYear, viewMonth) { + history.filter { + val instant = parseHistoryInstant(it.date) ?: return@filter false + val c = Calendar.getInstance().apply { time = Date.from(instant) } + c.get(Calendar.YEAR) == viewYear && c.get(Calendar.MONTH) == viewMonth + } + } + val monthTotalVolume = remember(monthSessions) { monthSessions.sumOf { calcSessionVolume(it) } } + // GAP-19: ISO week Mon=0..Sun=6; Calendar.DAY_OF_WEEK is Sun=1..Sat=7, so Mon=(dow-2+7)%7 + val firstDayOfWeek = remember(viewYear, viewMonth) { + val dow = Calendar.getInstance().apply { set(viewYear, viewMonth, 1) }.get(Calendar.DAY_OF_WEEK) + (dow - 2 + 7) % 7 + } + val daysInMonth = remember(viewYear, viewMonth) { Calendar.getInstance().apply { set(viewYear, viewMonth + 1, 0) }.get(Calendar.DAY_OF_MONTH) } + val todayKey = remember { SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()) } + val fmt = remember { SimpleDateFormat("yyyy-MM-dd", Locale.US) } + + // Week view: compute the 7 days of the offset week (Sun..Sat) + val weekDays = remember(weekOffset) { + val cal = Calendar.getInstance() + cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0) + cal.set(Calendar.SECOND, 0); cal.set(Calendar.MILLISECOND, 0) + cal.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY) + cal.add(Calendar.WEEK_OF_YEAR, weekOffset) + (0..6).map { offset -> + val d = cal.clone() as Calendar + d.add(Calendar.DATE, offset) + d + } + } + val weekLabel = remember(weekDays) { + val startFmt = SimpleDateFormat("MMM d", Locale.US) + val endFmt = SimpleDateFormat("MMM d, yyyy", Locale.US) + "${startFmt.format(weekDays.first().time)} – ${endFmt.format(weekDays.last().time)}" + } + // Sessions for the selected week (agenda list) + val weekSessions = remember(history, weekDays) { + val weekKeys = weekDays.map { fmt.format(it.time) }.toSet() + history.filter { it.date.substringBefore('T') in weekKeys } + .sortedBy { it.date } + } + + fun prevMonth() { if (viewMonth == 0) { viewMonth = 11; viewYear -= 1 } else viewMonth -= 1 } + fun nextMonth() { if (viewMonth == 11) { viewMonth = 0; viewYear += 1 } else viewMonth += 1 } + fun dayKey(day: Int) = "%04d-%02d-%02d".format(viewYear, viewMonth + 1, day) + + Column(Modifier.fillMaxSize().background(colors.bg).statusBarsPadding().verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 100.dp)) { + ScreenHeader(title = "CALENDAR", onBack = onBack) + + // View toggle: Month | Week + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.lg.dp)) + .background(colors.surface) + .border(1.dp, colors.cardBorder, RoundedCornerShape(IronLogRadius.lg.dp)) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + listOf("Month" to false, "Week" to true).forEach { (label, weekMode) -> + val selected = showWeekView == weekMode + Box( + Modifier + .weight(1f) + .clip(RoundedCornerShape(IronLogRadius.md.dp)) + .background(if (selected) colors.accent else androidx.compose.ui.graphics.Color.Transparent) + .clickable { showWeekView = weekMode } + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + label, + color = if (selected) colors.textOnAccent else colors.subtext, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + fontSize = IronLogType.body.fontSize.sp, + ) + } + } + } + Spacer(Modifier.height(12.dp)) + + // Stats row + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + StatCard("STREAK", streak.toString(), Modifier.weight(1f)) + if (showWeekView) { + StatCard("THIS WEEK", weekSessions.size.toString(), Modifier.weight(1f)) + val wVol = toDisplayVolume(weekSessions.sumOf { calcSessionVolume(it) }, weightUnit) + StatCard("VOLUME ($weightUnit)", if (wVol >= 1000) "%.1fk".format(wVol / 1000.0) else wVol.toString(), Modifier.weight(1f)) + } else { + StatCard("THIS MONTH", monthSessions.size.toString(), Modifier.weight(1f)) + val displayVol = toDisplayVolume(monthTotalVolume, weightUnit) + StatCard("VOLUME ($weightUnit)", if (displayVol >= 1000) "%.1fk".format(displayVol / 1000.0) else displayVol.toString(), Modifier.weight(1f)) + } + } + Spacer(Modifier.height(12.dp)) + + if (showWeekView) { + // ── Week view ──────────────────────────────────────────────────── + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(44.dp).clickable { weekOffset -= 1 }, contentAlignment = Alignment.Center) { + Text("‹", color = colors.text, fontSize = IronLogType.display.fontSize.sp) + } + Text(weekLabel, color = colors.text, fontSize = IronLogType.body.fontSize.sp, fontWeight = FontWeight.Medium) + Box(Modifier.size(44.dp).clickable { weekOffset += 1 }, contentAlignment = Alignment.Center) { + Text("›", color = colors.text, fontSize = IronLogType.display.fontSize.sp) + } + } + // Day cells + Column(Modifier.fillMaxWidth().background(colors.card, RoundedCornerShape(16.dp)).border(1.dp, colors.cardBorder, RoundedCornerShape(16.dp)).padding(8.dp)) { + Row(Modifier.fillMaxWidth()) { + DAY_HEADERS.forEach { Text(it, color = colors.muted, modifier = Modifier.weight(1f), fontSize = IronLogType.meta.fontSize.sp) } + } + Spacer(Modifier.height(4.dp)) + Row(Modifier.fillMaxWidth()) { + weekDays.forEach { cal -> + val key = fmt.format(cal.time) + val sessions = sessionsByDate[key].orEmpty() + val isToday = key == todayKey + val isPast = key <= todayKey // includes today (can log on today) + val isFuture = key > todayKey + Box( + Modifier.weight(1f).height(56.dp).padding(2.dp) + .background(if (isToday) colors.accentSoft else colors.surface, RoundedCornerShape(10.dp)) + .border(1.dp, when { + sessions.isNotEmpty() -> colors.accent + !isFuture -> colors.cardBorder + else -> colors.faint + }, RoundedCornerShape(10.dp)) + .combinedClickable( + onClick = { + if (!isFuture) { + if (sessions.isNotEmpty()) selectedSession = sessions.last() + else addForDate = key + } + }, + onLongClick = { + if (!isFuture && sessions.isEmpty()) onStartWorkout(key) + }, + ), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(cal.get(Calendar.DAY_OF_MONTH).toString(), + color = if (isFuture) colors.muted else colors.text, + fontSize = IronLogType.body.fontSize.sp) + // GAP-06: session count badge + if (sessions.isNotEmpty()) { + if (sessions.size > 1) { + Box( + Modifier.size(14.dp).background(colors.accent, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text(sessions.size.toString(), color = colors.textOnAccent, fontSize = 7.sp, fontWeight = FontWeight.Bold) + } + } else { + Box(Modifier.size(6.dp).background(colors.accent, CircleShape)) + } + } else if (!isFuture) { + // Subtle + icon on empty past cells + Icon(Icons.Filled.Add, contentDescription = "Log workout", + tint = colors.muted, modifier = Modifier.size(12.dp)) + } + } + } + } + } + } + // Agenda list for the week + if (weekSessions.isNotEmpty()) { + Spacer(Modifier.height(16.dp)) + Text( + "SESSIONS", + color = colors.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = IronLogType.eyebrow.letterSpacing.sp, + ) + Spacer(Modifier.height(8.dp)) + weekSessions.forEach { session -> + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.md.dp)) + .background(colors.card) + .border(1.dp, colors.cardBorder, RoundedCornerShape(IronLogRadius.md.dp)) + .clickable { selectedSession = session } + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(session.name.uppercase(), color = colors.accent, fontWeight = FontWeight.Bold, fontSize = IronLogType.body.fontSize.sp) + Text( + "${session.sets} sets · ${formatDurationMins(session.duration)}", + color = colors.muted, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + Text( + session.date.substringBefore('T'), + color = colors.subtext, + fontSize = IronLogType.meta.fontSize.sp, + ) + } + Spacer(Modifier.height(8.dp)) + } + } else { + Spacer(Modifier.height(24.dp)) + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text("No workouts this week", color = colors.muted, fontSize = IronLogType.body.fontSize.sp) + } + } + } else { + // ── Month view ─────────────────────────────────────────────────── + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(44.dp).clickable { prevMonth() }, contentAlignment = Alignment.Center) { + Text("‹", color = colors.text, fontSize = IronLogType.display.fontSize.sp) + } + Text("${MONTH_NAMES[viewMonth]} $viewYear", color = colors.text, fontSize = IronLogType.title.fontSize.sp) + Box(Modifier.size(44.dp).clickable { nextMonth() }, contentAlignment = Alignment.Center) { + Text("›", color = colors.text, fontSize = IronLogType.display.fontSize.sp) + } + } + Column(Modifier.fillMaxWidth().background(colors.card, RoundedCornerShape(16.dp)).border(1.dp, colors.cardBorder, RoundedCornerShape(16.dp)).padding(8.dp)) { + Row(Modifier.fillMaxWidth()) { DAY_HEADERS.forEach { Text(it, color = colors.muted, modifier = Modifier.weight(1f), fontSize = IronLogType.meta.fontSize.sp) } } + val cells = buildList { repeat(firstDayOfWeek) { add(null) }; for (d in 1..daysInMonth) add(d); while (size % 7 != 0) add(null) } + cells.chunked(7).forEach { row -> + Row(Modifier.fillMaxWidth()) { + row.forEach { day -> + val key = day?.let { dayKey(it) } + val sessions = key?.let { sessionsByDate[it] }.orEmpty() + val isToday = key == todayKey + val isFuture = key != null && key > todayKey + val isPast = key != null && !isFuture + Box( + Modifier.weight(1f).height(48.dp).padding(2.dp) + .background(if (isToday) colors.accentSoft else colors.surface, RoundedCornerShape(10.dp)) + .border(1.dp, when { + sessions.isNotEmpty() -> colors.accent + isPast -> colors.cardBorder + else -> colors.faint + }, RoundedCornerShape(10.dp)) + .then(if (key != null) Modifier.combinedClickable( + onClick = { + if (isPast) { + if (sessions.isNotEmpty()) selectedSession = sessions.last() + else addForDate = key + } + }, + onLongClick = { + if (isPast && sessions.isEmpty()) onStartWorkout(key) + }, + ) else Modifier), + contentAlignment = Alignment.Center + ) { + // GAP-06: session count badge for month view + if (day != null) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Text(day.toString(), + color = if (isFuture) colors.muted else colors.text, + fontSize = IronLogType.body.fontSize.sp) + if (sessions.isNotEmpty()) { + if (sessions.size > 1) { + Box( + Modifier.size(13.dp).background(colors.accent, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text(sessions.size.toString(), color = colors.textOnAccent, fontSize = 7.sp, fontWeight = FontWeight.Bold) + } + } else { + Box(Modifier.size(5.dp).background(colors.accent, CircleShape)) + } + } + } + } + } + } + } + } + } + } + } + selectedSession?.let { session -> + SessionDetailDialog(session = session, weightUnit = weightUnit, onDismiss = { selectedSession = null }) + } + + addForDate?.let { dateKey -> + AddWorkoutForDateSheet( + dateKey = dateKey, + onDismiss = { addForDate = null }, + onConfirm = { input -> + onLogWorkout(input) + addForDate = null + }, + ) + } +} + +/** Bottom-sheet style dialog for logging a manual workout on a past date. */ +@Composable +private fun AddWorkoutForDateSheet( + dateKey: String, // "yyyy-MM-dd" + onDismiss: () -> Unit, + onConfirm: (CreateCompletedWorkoutInput) -> Unit, +) { + val c = useTheme() + var workoutName by remember { mutableStateOf("") } + var durationMins by remember { mutableStateOf("") } + var notes by remember { mutableStateOf("") } + var rating by remember { mutableStateOf(3) } // 1–5 + + val displayDate = remember(dateKey) { + runCatching { + val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + val cal = Calendar.getInstance().apply { time = sdf.parse(dateKey)!! } + SimpleDateFormat("EEEE, MMMM d, yyyy", Locale.US).format(cal.time) + }.getOrElse { dateKey } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Column( + Modifier + .fillMaxWidth(0.94f) + .clip(RoundedCornerShape(IronLogRadius.xl.dp)) + .background(c.card) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + // Header + Text("LOG WORKOUT", color = c.accent, fontWeight = FontWeight.Black, + fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 2.sp) + Text(displayDate, color = c.subtext, fontSize = IronLogType.body.fontSize.sp) + + // Workout name + OutlinedTextField( + value = workoutName, + onValueChange = { workoutName = it }, + label = { Text("Workout name (optional)", color = c.muted) }, + placeholder = { Text("e.g. Push Day", color = c.muted.copy(alpha = 0.5f)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = c.accent, + unfocusedBorderColor = c.cardBorder, + focusedTextColor = c.text, + unfocusedTextColor = c.text, + ), + ) + + // Duration + OutlinedTextField( + value = durationMins, + onValueChange = { durationMins = it.filter { ch -> ch.isDigit() } }, + label = { Text("Duration (minutes)", color = c.muted) }, + placeholder = { Text("e.g. 60", color = c.muted.copy(alpha = 0.5f)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = c.accent, + unfocusedBorderColor = c.cardBorder, + focusedTextColor = c.text, + unfocusedTextColor = c.text, + ), + ) + + // Rating + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("RATING", color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, letterSpacing = 2.sp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + (1..5).forEach { star -> + Box( + Modifier + .size(40.dp) + .clip(CircleShape) + .background(if (star <= rating) c.accent else c.surface) + .border(1.dp, if (star <= rating) c.accent else c.cardBorder, CircleShape) + .clickable { rating = star }, + contentAlignment = Alignment.Center, + ) { + Text("$star", color = if (star <= rating) c.textOnAccent else c.muted, + fontWeight = FontWeight.Bold, fontSize = IronLogType.body.fontSize.sp) + } + } + } + } + + // Notes + OutlinedTextField( + value = notes, + onValueChange = { notes = it }, + label = { Text("Notes (optional)", color = c.muted) }, + minLines = 2, + maxLines = 4, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = c.accent, + unfocusedBorderColor = c.cardBorder, + focusedTextColor = c.text, + unfocusedTextColor = c.text, + ), + ) + + // Buttons + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { + Text("CANCEL", color = c.muted) + } + Button( + onClick = { + // Build startedAt = noon on the selected date + val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + val cal = Calendar.getInstance().apply { + time = runCatching { sdf.parse(dateKey)!! }.getOrElse { Date() } + set(Calendar.HOUR_OF_DAY, 12) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + val durationSec = (durationMins.toIntOrNull() ?: 0) * 60 + val name = workoutName.trim().ifBlank { "Manual Workout" } + onConfirm( + CreateCompletedWorkoutInput( + name = name, + startedAt = cal.timeInMillis, + durationSeconds = durationSec, + rating = rating.toDouble(), + notes = notes.trim().takeIf { it.isNotBlank() }, + exerciseData = emptyList(), + ) + ) + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = c.accent), + ) { + Text("SAVE", color = c.bg, fontWeight = FontWeight.Black, letterSpacing = 2.sp) + } + } + } + } +} + +@Composable +private fun SessionDetailDialog(session: HistoryEntry, weightUnit: String, onDismiss: () -> Unit) { + val c = useTheme() + val volume = calcSessionVolume(session) + val displayVol = toDisplayVolume(volume, weightUnit) + val volStr = if (displayVol >= 1000) "%.1fk".format(displayVol / 1000.0) else displayVol.toString() + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Column( + Modifier + .fillMaxWidth(0.92f) + .clip(RoundedCornerShape(IronLogRadius.xl.dp)) + .background(c.card) + .verticalScroll(rememberScrollState()), + ) { + // Header + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp)) { + Text( + session.name.uppercase(), + color = c.accent, + fontWeight = FontWeight(IronLogType.section.fontWeight), + fontSize = IronLogType.section.fontSize.sp, + letterSpacing = 0.5.sp, + ) + Text( + formatDateLong(session.date), + color = c.subtext, + fontSize = IronLogType.body.fontSize.sp, + ) + } + // Stats row: SETS | DURATION | VOL + Row( + Modifier + .fillMaxWidth() + .border(width = 1.dp, color = c.faint, shape = RoundedCornerShape(0.dp)) + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + StatPill(value = session.sets.toString(), label = "SETS") + Box(Modifier.width(1.dp).height(32.dp).background(c.faint)) + StatPill(value = formatDurationMins(session.duration), label = "DURATION") + Box(Modifier.width(1.dp).height(32.dp).background(c.faint)) + StatPill(value = volStr, label = "VOL ($weightUnit)") + } + // Exercise breakdown + if (session.exercises.isNotEmpty()) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "EXERCISES", + color = c.muted, + fontSize = IronLogType.eyebrow.fontSize.sp, + fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), + letterSpacing = 2.sp, + ) + session.exercises.forEach { ex -> + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(IronLogRadius.sm.dp)) + .background(c.bg) + .border(1.dp, c.faint, RoundedCornerShape(IronLogRadius.sm.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(ex.name, color = c.text, fontWeight = FontWeight.SemiBold, fontSize = IronLogType.body.fontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (ex.sets.isNotEmpty()) { + val setStr = ex.sets.joinToString(" / ") { s -> + val w = if (s.weight > 0) formatWeightFromKg(s.weight, weightUnit) else "BW" + "$w × ${s.reps.roundToInt()}" + } + Text(setStr, color = c.muted, fontSize = IronLogType.meta.fontSize.sp, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + } + } else { + Box(Modifier.fillMaxWidth().padding(20.dp), contentAlignment = Alignment.Center) { + Text("No exercise details recorded.", color = c.muted, fontSize = IronLogType.body.fontSize.sp) + } + } + // Close + Box(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), contentAlignment = Alignment.CenterEnd) { + TextButton(onClick = onDismiss) { Text("CLOSE", color = c.accent) } + } + } + } +} + +@Composable +private fun StatPill(value: String, label: String) { + val c = useTheme() + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(value, color = c.text, fontWeight = FontWeight.Bold, fontSize = IronLogType.title.fontSize.sp) + Text(label, color = c.muted, fontSize = IronLogType.eyebrow.fontSize.sp, fontWeight = FontWeight(IronLogType.eyebrow.fontWeight), letterSpacing = 1.sp) + } +} + +@Composable private fun StatCard(label: String, value: String, modifier: Modifier = Modifier) { + val colors = useTheme() + Column(modifier.background(colors.card, RoundedCornerShape(12.dp)).border(1.dp, colors.cardBorder, RoundedCornerShape(12.dp)).padding(10.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(value, color = colors.text, fontSize = IronLogType.title.fontSize.sp) + Text(label, color = colors.muted, fontSize = IronLogType.micro.fontSize.sp) + } +} + diff --git a/app/src/main/java/com/ironlog/app/ui/screens/workout/WorkoutExerciseBinding.kt b/app/src/main/java/com/ironlog/app/ui/screens/workout/WorkoutExerciseBinding.kt new file mode 100644 index 0000000..fea3ea2 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/screens/workout/WorkoutExerciseBinding.kt @@ -0,0 +1,30 @@ +package com.ironlog.app.ui.screens.workout + +internal data class WorkoutExerciseBinding( + val workoutExerciseId: String, + val exerciseId: String, + val orderIndex: Int, +) + +internal fun buildWorkoutExerciseIndexMap( + exerciseIdsInUiOrder: List, + workoutExerciseRows: List, +): Map { + if (exerciseIdsInUiOrder.isEmpty() || workoutExerciseRows.isEmpty()) return emptyMap() + + val orderedRows = workoutExerciseRows.sortedBy { it.orderIndex } + val rowsByExercise = orderedRows + .groupBy { it.exerciseId } + .mapValues { (_, rows) -> ArrayDeque(rows) } + .toMutableMap() + + val result = mutableMapOf() + exerciseIdsInUiOrder.forEachIndexed { index, exerciseId -> + val exact = rowsByExercise[exerciseId]?.removeFirstOrNull() + val fallback = orderedRows.getOrNull(index) + val binding = exact ?: fallback + if (binding != null) result[index] = binding.workoutExerciseId + } + return result +} + diff --git a/app/src/main/java/com/ironlog/app/ui/state/WorkoutState.kt b/app/src/main/java/com/ironlog/app/ui/state/WorkoutState.kt new file mode 100644 index 0000000..c9469a6 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/state/WorkoutState.kt @@ -0,0 +1,219 @@ +package com.ironlog.app.ui.state + +import androidx.compose.runtime.Immutable +import com.ironlog.app.util.convertKgToUnit +import com.ironlog.app.util.epley +import java.util.UUID +import kotlin.math.roundToInt + +@Immutable +data class WorkoutInput(val weight: String = "", val reps: String = "") + +@Immutable +data class LoggedSet( + val id: String = genId(), + val weight: Double = 0.0, + val reps: Double = 0.0, + val type: String = "normal", + val rpe: Double? = null, + val rir: Int? = null, + val note: String? = null, + val orm: Double = 0.0, + val trackingType: String = "weight_reps", + val durationSec: Double? = null, +) + +@Immutable +data class GhostSet(val weight: Double = 0.0, val reps: Double = 0.0, val type: String = "normal", val rpe: Double? = null) + +@Immutable +data class GhostData(val sets: List = emptyList(), val date: String? = null) + +@Immutable +data class RestTimerState( + val active: Boolean = false, + val endTime: Long? = null, + val total: Int = 0, + val paused: Boolean = false, + val pausedAt: Long? = null, + val triggerExIndex: Int? = null, +) + +/** Session-only override of the plan target (sets × reps) for a single exercise. */ +@Immutable +data class TargetOverride(val sets: Int, val reps: Int) + +@Immutable +data class WorkoutState( + val inputs: Map = emptyMap(), + val setLog: Map> = emptyMap(), + val ghostData: Map = emptyMap(), + val exerciseNotes: Map = emptyMap(), + val supersetGroups: Map = emptyMap(), + val restTimer: RestTimerState = RestTimerState(), + val swappedExercises: Map = emptyMap(), + val addedExercises: List = emptyList(), + val pbNotif: String? = null, + val copiedPrevious: Boolean = false, + val targetOverrides: Map = emptyMap(), // GAP-23 + val removedBaseExerciseIndices: Set = emptySet(), +) + +@Immutable +data class AddedExerciseEntry( + val exerciseId: String, + val name: String, + val trackingType: String = "weight_reps", + val equipment: String? = null, + val sets: Int = 3, + val reps: Int = 8, +) + +sealed interface WorkoutAction { + data class SetInput(val exIndex: Int, val weight: String? = null, val reps: String? = null) : WorkoutAction + data class LogSet(val exIndex: Int, val set: LoggedSet) : WorkoutAction + data class SetType(val exIndex: Int, val setIndex: Int, val type: String) : WorkoutAction + data class SetNote(val exIndex: Int, val setIndex: Int, val note: String?) : WorkoutAction + data class SetRpe(val exIndex: Int, val setIndex: Int, val rpe: Double?) : WorkoutAction + data class SetRir(val exIndex: Int, val setIndex: Int, val rir: Int?) : WorkoutAction + data class UpdateSet(val exIndex: Int, val setIndex: Int, val weight: Double?, val reps: Double?) : WorkoutAction + data class DeleteSet(val exIndex: Int, val setIndex: Int) : WorkoutAction + data class LoadGhost(val ghostData: Map) : WorkoutAction + data class SetExerciseNote(val exIndex: Int, val note: String) : WorkoutAction + data class AssignSuperset(val exIndex: Int, val group: String?) : WorkoutAction + data class StartRest(val endTime: Long, val total: Int, val triggerExIndex: Int?) : WorkoutAction + data class PauseRest(val pausedAt: Long) : WorkoutAction + data class ResumeRest(val newEndTime: Long) : WorkoutAction + data object SkipRest : WorkoutAction + data object Add30s : WorkoutAction + data class QuickAddSet(val exIndex: Int) : WorkoutAction + data class SwapExercise(val exIndex: Int, val exercise: Any) : WorkoutAction + data class SetPbNotif(val message: String?) : WorkoutAction + data class InsertWarmups(val exIndex: Int, val warmupSets: List) : WorkoutAction + data class UpdateGhost(val exIndex: Int, val ghost: GhostData) : WorkoutAction + data class CopyPrevious(val weightUnit: String = "kg") : WorkoutAction + data class AddExercise(val entry: AddedExerciseEntry) : WorkoutAction + data class RemoveExercise(val exIndex: Int, val baseExercisesCount: Int = 0, val removedBaseIndex: Int? = null) : WorkoutAction + data class HydrateState(val payload: WorkoutState?) : WorkoutAction + /** GAP-23: Override the plan target for this exercise for the current session only. */ + data class OverrideTarget(val exIndex: Int, val sets: Int, val reps: Int) : WorkoutAction +} + +fun genId(): String = UUID.randomUUID().toString().replace("-", "").take(12) + +fun workoutReducer(state: WorkoutState, action: WorkoutAction, nowMs: Long = System.currentTimeMillis()): WorkoutState = when (action) { + is WorkoutAction.SetInput -> { + val prev = state.inputs[action.exIndex] ?: WorkoutInput() + val next = WorkoutInput(action.weight ?: prev.weight, action.reps ?: prev.reps) + if (prev == next) state else state.copy(inputs = state.inputs + (action.exIndex to next)) + } + + is WorkoutAction.LogSet -> { + val isTimeBased = action.set.trackingType.startsWith("duration") + val orm = if (!isTimeBased && action.set.weight > 0 && action.set.reps > 0) epley(action.set.weight, action.set.reps) else 0.0 + val newSet = action.set.copy(id = genId(), orm = orm, type = action.set.type.ifBlank { "normal" }, rpe = action.set.rpe, rir = action.set.rir, note = action.set.note) + val existing = state.setLog[action.exIndex].orEmpty() + state.copy(setLog = state.setLog + (action.exIndex to (existing + newSet))) + } + + is WorkoutAction.SetType -> updateLoggedSet(state, action.exIndex, action.setIndex) { it.copy(type = action.type) } + is WorkoutAction.SetNote -> updateLoggedSet(state, action.exIndex, action.setIndex) { it.copy(note = action.note) } + is WorkoutAction.SetRpe -> updateLoggedSet(state, action.exIndex, action.setIndex) { it.copy(rpe = action.rpe) } + is WorkoutAction.SetRir -> updateLoggedSet(state, action.exIndex, action.setIndex) { it.copy(rir = action.rir) } + + is WorkoutAction.UpdateSet -> updateLoggedSet(state, action.exIndex, action.setIndex) { current -> + val isTimeBased = current.trackingType.startsWith("duration") + val nextWeight = action.weight?.takeIf { it.isFinite() } ?: current.weight + val nextReps = action.reps?.takeIf { it.isFinite() } ?: current.reps + val orm = if (!isTimeBased && nextWeight > 0 && nextReps > 0) epley(nextWeight, nextReps) else 0.0 + current.copy(weight = nextWeight, reps = nextReps, orm = orm, durationSec = if (isTimeBased) nextReps else current.durationSec) + } + + is WorkoutAction.DeleteSet -> { + val sets = state.setLog[action.exIndex].orEmpty().toMutableList() + if (action.setIndex !in sets.indices) state else { + sets.removeAt(action.setIndex) + state.copy(setLog = state.setLog + (action.exIndex to sets)) + } + } + + is WorkoutAction.LoadGhost -> state.copy(ghostData = action.ghostData) + is WorkoutAction.SetExerciseNote -> state.copy(exerciseNotes = state.exerciseNotes + (action.exIndex to action.note)) + is WorkoutAction.AssignSuperset -> state.copy(supersetGroups = state.supersetGroups + (action.exIndex to action.group)) + is WorkoutAction.StartRest -> state.copy(restTimer = RestTimerState(active = true, endTime = action.endTime, total = action.total, paused = false, pausedAt = null, triggerExIndex = action.triggerExIndex)) + is WorkoutAction.PauseRest -> if (!state.restTimer.active || state.restTimer.paused) state else state.copy(restTimer = state.restTimer.copy(paused = true, pausedAt = action.pausedAt)) + is WorkoutAction.ResumeRest -> if (!state.restTimer.paused) state else state.copy(restTimer = state.restTimer.copy(paused = false, pausedAt = null, endTime = action.newEndTime)) + WorkoutAction.SkipRest -> state.copy(restTimer = RestTimerState()) + WorkoutAction.Add30s -> { + if (!state.restTimer.active) state else state.copy( + restTimer = state.restTimer.copy( + endTime = (state.restTimer.endTime ?: nowMs) + 30_000L, + total = state.restTimer.total + 30, + ), + ) + } + is WorkoutAction.QuickAddSet -> { + val last = state.setLog[action.exIndex].orEmpty().lastOrNull() + if (last == null) state else state.copy(inputs = state.inputs + (action.exIndex to WorkoutInput(last.weight.toCleanString(), last.reps.toCleanString()))) + } + is WorkoutAction.SwapExercise -> state.copy(swappedExercises = state.swappedExercises + (action.exIndex to action.exercise)) + is WorkoutAction.SetPbNotif -> state.copy(pbNotif = action.message) + is WorkoutAction.InsertWarmups -> { + val workingSets = state.setLog[action.exIndex].orEmpty().filter { (it.type.ifBlank { "normal" }) != "warmup" } + state.copy(setLog = state.setLog + (action.exIndex to (action.warmupSets + workingSets))) + } + is WorkoutAction.UpdateGhost -> state.copy(ghostData = state.ghostData + (action.exIndex to action.ghost)) + is WorkoutAction.CopyPrevious -> { + val newInputs = state.inputs.toMutableMap() + state.ghostData.forEach { (idx, ghost) -> + val first = ghost.sets.firstOrNull() + if (first != null) { + newInputs[idx] = WorkoutInput( + weight = if (first.weight > 0) convertKgToUnit(first.weight, action.weightUnit, if (action.weightUnit == "lbs") 0 else 1).toCleanString() else "", + reps = if (first.reps > 0) first.reps.toCleanString() else "", + ) + } + } + state.copy(inputs = newInputs, copiedPrevious = true) + } + is WorkoutAction.AddExercise -> state.copy(addedExercises = state.addedExercises + action.entry) + is WorkoutAction.RemoveExercise -> { + val exIdx = action.exIndex + val addedLocalIdx = exIdx - action.baseExercisesCount + val newAddedExercises = if (addedLocalIdx >= 0 && addedLocalIdx in state.addedExercises.indices) { + state.addedExercises.toMutableList().also { it.removeAt(addedLocalIdx) } + } else { + state.addedExercises + } + // Re-index all maps: drop the removed entry, shift keys above it down by 1. + fun reIndex(map: Map): Map = + map.filterKeys { it != exIdx }.mapKeys { (k, _) -> if (k > exIdx) k - 1 else k } + val newRemovedBaseIndices = if (action.removedBaseIndex != null) { + state.removedBaseExerciseIndices + action.removedBaseIndex + } else { + state.removedBaseExerciseIndices + } + state.copy( + addedExercises = newAddedExercises, + inputs = reIndex(state.inputs), + setLog = reIndex(state.setLog), + ghostData = reIndex(state.ghostData), + exerciseNotes = reIndex(state.exerciseNotes), + supersetGroups = reIndex(state.supersetGroups), + swappedExercises = reIndex(state.swappedExercises), + targetOverrides = reIndex(state.targetOverrides), + removedBaseExerciseIndices = newRemovedBaseIndices, + ) + } + is WorkoutAction.HydrateState -> (action.payload ?: WorkoutState()).copy(ghostData = state.ghostData, pbNotif = null, targetOverrides = state.targetOverrides) + is WorkoutAction.OverrideTarget -> state.copy(targetOverrides = state.targetOverrides + (action.exIndex to TargetOverride(action.sets, action.reps))) +} + +private fun updateLoggedSet(state: WorkoutState, exIndex: Int, setIndex: Int, transform: (LoggedSet) -> LoggedSet): WorkoutState { + val sets = state.setLog[exIndex].orEmpty().toMutableList() + if (setIndex !in sets.indices) return state + sets[setIndex] = transform(sets[setIndex]) + return state.copy(setLog = state.setLog + (exIndex to sets)) +} + +private fun Double.toCleanString(): String = if (this % 1.0 == 0.0) this.roundToInt().toString() else this.toString() diff --git a/app/src/main/java/com/ironlog/app/ui/theme/IronLogShapes.kt b/app/src/main/java/com/ironlog/app/ui/theme/IronLogShapes.kt new file mode 100644 index 0000000..48291e0 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/theme/IronLogShapes.kt @@ -0,0 +1,13 @@ +package com.ironlog.app.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val ObsidianShapes = Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(24.dp), + extraLarge = RoundedCornerShape(32.dp) +) diff --git a/app/src/main/java/com/ironlog/app/ui/theme/IronLogThemes.kt b/app/src/main/java/com/ironlog/app/ui/theme/IronLogThemes.kt new file mode 100644 index 0000000..b9edcfa --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/theme/IronLogThemes.kt @@ -0,0 +1,182 @@ +package com.ironlog.app.ui.theme + +import android.os.Build +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +// FIXED: 4 — Elevation contract (keep consistent across all screens): +// c.bg = page background (LazyColumn/screen fill) +// c.surface = subtle fill for inputs, chips, tags, secondary containers +// c.card = primary lifted card containers +// c.card + c.cardBorder border = standard bordered card + +/** Translation of themes.js. PlatformColor/Monet tokens map to dynamic color integration points. */ +@Immutable +data class IronLogThemeTokens( + val name: String, + val bg: Color, + val card: Color, + val cardBorder: Color, + val surface: Color, + val accent: Color, + val accentSoft: Color, + val accentBorder: Color, + val text: Color, + val subtext: Color, + val muted: Color, + val faint: Color, + val tabBg: Color, + val gold: Color, + val textOnAccent: Color, + val onCard: Color, + val ghostText: Color, + val success: Color, + val warning: Color, + val danger: Color, + val info: Color, + val onDanger: Color, + val chartPrimary: Color, + val chartSecondary: Color, + val chartTertiary: Color, +) + +object IronLogThemes { + const val DEFAULT_THEME = "dark" + + val BurntTerracotta = IronLogThemeTokens( + name = "BURNT_TERRACOTTA", bg = Color(0xFF161311), card = Color(0xFF221F1D), cardBorder = Color(0xFF383432), surface = Color(0xFF221F1D), + accent = Color(0xFFFFB77D), accentSoft = Color(0x33FFB77D), accentBorder = Color(0x66FFB77D), text = Color(0xFFE9E1DD), subtext = Color(0xFFD2C3BA), + muted = Color(0xFFA38C7C), faint = Color(0xFF383432), tabBg = Color(0xFF161311), gold = Color(0xFFEAC33E), textOnAccent = Color(0xFF4D2600), + onCard = Color(0xFFE9E1DD), ghostText = Color(0xFF7A665A), success = Color(0xFF8FCA88), warning = Color(0xFFEAC33E), danger = Color(0xFFFFB4AB), + info = Color(0xFF90CDFF), onDanger = Color(0xFF690005), chartPrimary = Color(0xFFFFB77D), chartSecondary = Color(0xFF90CDFF), chartTertiary = Color(0xFFEAC33E), + ) + + val CrimsonSteel = IronLogThemeTokens( + name = "CRIMSON_STEEL", bg = Color(0xFF141313), card = Color(0xFF211F1F), cardBorder = Color(0xFF363434), surface = Color(0xFF211F1F), + accent = Color(0xFFFFB4AB), accentSoft = Color(0x33FFB4AB), accentBorder = Color(0x66FFB4AB), text = Color(0xFFE6E1E1), subtext = Color(0xFFCFC8C8), + muted = Color(0xFFAC8884), faint = Color(0xFF363434), tabBg = Color(0xFF141313), gold = Color(0xFFCFB26B), textOnAccent = Color(0xFF690005), + onCard = Color(0xFFE6E1E1), ghostText = Color(0xFF7E6B69), success = Color(0xFF9ACB9A), warning = Color(0xFFE1BC68), danger = Color(0xFFFFB4AB), + info = Color(0xFF90CDFF), onDanger = Color(0xFF690005), chartPrimary = Color(0xFFFFB4AB), chartSecondary = Color(0xFF90CDFF), chartTertiary = Color(0xFFC6C5CF), + ) + + val DeepForest = IronLogThemeTokens( + name = "DEEP_FOREST", bg = Color(0xFF121412), card = Color(0xFF1E201E), cardBorder = Color(0xFF333533), surface = Color(0xFF1E201E), + accent = Color(0xFFB0CFAD), accentSoft = Color(0x33B0CFAD), accentBorder = Color(0x66B0CFAD), text = Color(0xFFE2E3DF), subtext = Color(0xFFC3C8BF), + muted = Color(0xFF8D928A), faint = Color(0xFF333533), tabBg = Color(0xFF121412), gold = Color(0xFFD6BC6A), textOnAccent = Color(0xFF1D361E), + onCard = Color(0xFFE2E3DF), ghostText = Color(0xFF6F766D), success = Color(0xFFB0CFAD), warning = Color(0xFFD6BC6A), danger = Color(0xFFFFB4AB), + info = Color(0xFF8EC9BE), onDanger = Color(0xFF690005), chartPrimary = Color(0xFFB0CFAD), chartSecondary = Color(0xFF8EC9BE), chartTertiary = Color(0xFFBECABC), + ) + + val ElectricLemon = IronLogThemeTokens( + name = "ELECTRIC_LEMON", bg = Color(0xFF17130A), card = Color(0xFF241F15), cardBorder = Color(0xFF3A3429), surface = Color(0xFF241F15), + accent = Color(0xFFFFD165), accentSoft = Color(0x33FFD165), accentBorder = Color(0x66FFD165), text = Color(0xFFECE1D1), subtext = Color(0xFFD9C9B0), + muted = Color(0xFF9B8F79), faint = Color(0xFF3A3429), tabBg = Color(0xFF17130A), gold = Color(0xFFFFD165), textOnAccent = Color(0xFF3F2E00), + onCard = Color(0xFFECE1D1), ghostText = Color(0xFF7A705E), success = Color(0xFFC0D58D), warning = Color(0xFFFFD165), danger = Color(0xFFFFB4AB), + info = Color(0xFFADDDFF), onDanger = Color(0xFF690005), chartPrimary = Color(0xFFFFD165), chartSecondary = Color(0xFFADDDFF), chartTertiary = Color(0xFFC6C6CF), + ) + + val MidnightTeal = IronLogThemeTokens( + name = "MIDNIGHT_TEAL", bg = Color(0xFF0B1326), card = Color(0xFF171F33), cardBorder = Color(0xFF2D3449), surface = Color(0xFF171F33), + accent = Color(0xFF6BD8CB), accentSoft = Color(0x336BD8CB), accentBorder = Color(0x666BD8CB), text = Color(0xFFDAE2FD), subtext = Color(0xFFBFCBEA), + muted = Color(0xFF879391), faint = Color(0xFF2D3449), tabBg = Color(0xFF0B1326), gold = Color(0xFFD2C37D), textOnAccent = Color(0xFF003732), + onCard = Color(0xFFDAE2FD), ghostText = Color(0xFF6C7895), success = Color(0xFF6BD8CB), warning = Color(0xFFD2C37D), danger = Color(0xFFFFB2B7), + info = Color(0xFF7BD0FF), onDanger = Color(0xFF690005), chartPrimary = Color(0xFF6BD8CB), chartSecondary = Color(0xFF7BD0FF), chartTertiary = Color(0xFFFFB2B7), + ) + + val ObsidianSilver = IronLogThemeTokens( + name = "OBSIDIAN_SILVER", bg = Color(0xFF131313), card = Color(0xFF252525), cardBorder = Color(0xFF3D3D3D), surface = Color(0xFF252525), + accent = Color(0xFFFDFDFC), accentSoft = Color(0x33FDFDFC), accentBorder = Color(0x66C6C6C6), text = Color(0xFFE2E2E2), subtext = Color(0xFFC4C7C8), + muted = Color(0xFF8E9192), faint = Color(0xFF353535), tabBg = Color(0xFF131313), gold = Color(0xFFCACACA), textOnAccent = Color(0xFF2F3131), + onCard = Color(0xFFE2E2E2), ghostText = Color(0xFF727575), success = Color(0xFFBBD7BB), warning = Color(0xFFE5D39C), danger = Color(0xFFFFB4AB), + info = Color(0xFFAACEEE), onDanger = Color(0xFF690005), chartPrimary = Color(0xFFFDFDFC), chartSecondary = Color(0xFFC8C6C5), chartTertiary = Color(0xFFF7FEFF), + ) + + val RoyalAmethyst = IronLogThemeTokens( + name = "ROYAL_AMETHYST", bg = Color(0xFF0B1326), card = Color(0xFF171F33), cardBorder = Color(0xFF2D3449), surface = Color(0xFF171F33), + accent = Color(0xFFD2BBFF), accentSoft = Color(0x33D2BBFF), accentBorder = Color(0x66D2BBFF), text = Color(0xFFDAE2FD), subtext = Color(0xFFC9C4E6), + muted = Color(0xFF958DA1), faint = Color(0xFF2D3449), tabBg = Color(0xFF0B1326), gold = Color(0xFFD9BF74), textOnAccent = Color(0xFF3F008E), + onCard = Color(0xFFDAE2FD), ghostText = Color(0xFF726A88), success = Color(0xFFBFCBFF), warning = Color(0xFFD9BF74), danger = Color(0xFFFFB4AB), + info = Color(0xFFC4C1FB), onDanger = Color(0xFF690005), chartPrimary = Color(0xFFD2BBFF), chartSecondary = Color(0xFFCEBDFF), chartTertiary = Color(0xFFC4C1FB), + ) + + val TitaniumBlue = IronLogThemeTokens( + name = "TITANIUM_BLUE", bg = Color(0xFF11131C), card = Color(0xFF1D1F29), cardBorder = Color(0xFF33343E), surface = Color(0xFF1D1F29), + accent = Color(0xFFB8C3FF), accentSoft = Color(0x33B8C3FF), accentBorder = Color(0x66B8C3FF), text = Color(0xFFE2E1EF), subtext = Color(0xFFC4C5D9), + muted = Color(0xFF8E90A2), faint = Color(0xFF33343E), tabBg = Color(0xFF11131C), gold = Color(0xFFD1C58B), textOnAccent = Color(0xFF002388), + onCard = Color(0xFFE2E1EF), ghostText = Color(0xFF717487), success = Color(0xFF9BC0FF), warning = Color(0xFFD1C58B), danger = Color(0xFFFFB4AB), + info = Color(0xFF90CDFF), onDanger = Color(0xFF690005), chartPrimary = Color(0xFFB8C3FF), chartSecondary = Color(0xFFB7C8E1), chartTertiary = Color(0xFFBEC6E0), + ) + + val Amoled = IronLogThemeTokens( + name = "AMOLED", bg = Color(0xFF000000), card = Color(0xFF080808), cardBorder = Color(0xFF111111), + surface = Color(0xFF0D0D0D), accent = Color(0xFFFF4500), accentSoft = Color(0x22FF4500), accentBorder = Color(0x44FF4500), + text = Color.White, subtext = Color(0xFFAAAAAA), muted = Color(0xFF777777), faint = Color(0xFF1A1A1A), tabBg = Color.Black, + gold = Color(0xFFFFD700), textOnAccent = Color.White, onCard = Color.White, ghostText = Color(0xFF4D4D4D), + success = Color(0xFF00C170), warning = Color(0xFFFFB020), danger = Color(0xFFFF453A), info = Color(0xFF3B8FFF), onDanger = Color.White, + chartPrimary = Color(0xFFFF4500), chartSecondary = Color(0xFF3B8FFF), chartTertiary = Color(0xFF00C170), + ) + + val Dark = IronLogThemeTokens( + name = "DARK", bg = Color(0xFF121212), card = Color(0xFF1C1C1E), cardBorder = Color(0xFF2C2C2E), + surface = Color(0xFF252527), accent = Color(0xFFFF4500), accentSoft = Color(0x22FF4500), accentBorder = Color(0x55FF4500), + text = Color.White, subtext = Color(0xFFBBBBBB), muted = Color(0xFF909090), faint = Color(0xFF2C2C2E), tabBg = Color(0xFF1C1C1E), + gold = Color(0xFFFFD700), textOnAccent = Color.White, onCard = Color.White, ghostText = Color(0xFF555555), + success = Color(0xFF00C170), warning = Color(0xFFFFB020), danger = Color(0xFFFF453A), info = Color(0xFF3B8FFF), onDanger = Color.White, + chartPrimary = Color(0xFFFF4500), chartSecondary = Color(0xFF3B8FFF), chartTertiary = Color(0xFF00C170), + ) + + val MonetFallback = IronLogThemeTokens( + name = "MONET", bg = Color(0xFF1A1A2E), card = Color(0xFF252540), cardBorder = Color(0xFF3E3E5E), surface = Color(0xFF1E1E38), + accent = Color(0xFFD0BCFF), accentSoft = Color(0xFF3D2D6B), accentBorder = Color(0xFF7B5DB8), + text = Color(0xFFF0EEFF), subtext = Color(0xFFCCC8E8), muted = Color(0xFF8884A8), faint = Color(0xFF3E3E5E), tabBg = Color(0xFF1A1A2E), + gold = Color(0xFFF7D060), textOnAccent = Color(0xFF1A0B3B), onCard = Color(0xFFE8E4FF), ghostText = Color(0xFF55527A), + success = Color(0xFF79C98D), warning = Color(0xFFF7D060), danger = Color(0xFFFF8E8E), info = Color(0xFFA0C4FF), onDanger = Color(0xFF120C1F), + chartPrimary = Color(0xFFD0BCFF), chartSecondary = Color(0xFFA0C4FF), chartTertiary = Color(0xFF79C98D), + ) + + val Light = IronLogThemeTokens( + name = "LIGHT", bg = Color(0xFFF2F2F7), card = Color.White, cardBorder = Color(0xFFC8C8CC), surface = Color(0xFFF2F2F7), + accent = Color(0xFFD42010), accentSoft = Color(0x15D42010), accentBorder = Color(0x44D42010), + text = Color(0xFF111111), subtext = Color(0xFF333333), muted = Color(0xFF555555), faint = Color(0xFFE0E0E4), tabBg = Color.White, + gold = Color(0xFFB8720A), textOnAccent = Color.White, onCard = Color(0xFF111111), ghostText = Color(0xFF999999), + success = Color(0xFF1B8F4B), warning = Color(0xFFA15E00), danger = Color(0xFFC11E16), info = Color(0xFF0A63C9), onDanger = Color.White, + chartPrimary = Color(0xFFD42010), chartSecondary = Color(0xFF0A63C9), chartTertiary = Color(0xFF1B8F4B), + ) + + fun byName(name: String?): IronLogThemeTokens = when (name) { + "burnt_terracotta" -> BurntTerracotta + "crimson_steel" -> CrimsonSteel + "deep_forest" -> DeepForest + "electric_lemon" -> ElectricLemon + "midnight_teal" -> MidnightTeal + "obsidian_silver" -> ObsidianSilver + "royal_amethyst" -> RoyalAmethyst + "titanium_blue" -> TitaniumBlue + "dark" -> Dark + "light" -> Light + "monet" -> MonetFallback // Replace with Material You dynamic color tokens on Android 12+ during integration. + "amoled" -> Amoled + null -> Dark + else -> Dark + } +} + +object IronLogRadius { const val xs = 6; const val sm = 10; const val md = 14; const val lg = 18; const val xl = 24; const val full = 999 } +// Elevation contract: +// c.bg = page background (LazyColumn fill) +// c.surface = subtle fill for inputs, chips, tags, secondary containers +// c.card = primary lifted card containers +// c.card + c.cardBorder border = standard bordered card + +object IronLogType { + data class Token(val fontSize: Int, val fontWeight: Int, val letterSpacing: Double, val lineHeight: Int) + val display = Token(32, 900, -0.5, 36) + val metric = Token(28, 900, -1.0, 32) // large numeric displays (body weight, stat values) + val title = Token(20, 900, 0.3, 26) + val section = Token(16, 800, 0.2, 22) + val body = Token(14, 500, 0.0, 20) + val meta = Token(12, 500, 0.1, 16) + val eyebrow = Token(10, 800, 2.5, 14) + val button = Token(12, 800, 1.5, 16) + val micro = Token( 9, 800, 1.2, 12) +} diff --git a/app/src/main/java/com/ironlog/app/ui/theme/IronLogTypography.kt b/app/src/main/java/com/ironlog/app/ui/theme/IronLogTypography.kt new file mode 100644 index 0000000..ac75b2d --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/theme/IronLogTypography.kt @@ -0,0 +1,119 @@ +package com.ironlog.app.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.ironlog.app.R + +private val Lexend = FontFamily(Font(R.font.lexend_variable)) + +val ObsidianTypography = Typography( + displayLarge = TextStyle( + fontFamily = Lexend, + fontWeight = FontWeight.Bold, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + displayMedium = TextStyle( + fontFamily = Lexend, + fontWeight = FontWeight.Bold, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp + ), + displaySmall = TextStyle( + fontFamily = Lexend, + fontWeight = FontWeight.SemiBold, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp + ), + headlineLarge = TextStyle( + fontFamily = Lexend, + fontWeight = FontWeight.SemiBold, + fontSize = IronLogType.display.fontSize.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp + ), + headlineMedium = TextStyle( + fontFamily = Lexend, + fontWeight = FontWeight.SemiBold, + fontSize = IronLogType.metric.fontSize.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp + ), + headlineSmall = TextStyle( + fontFamily = Lexend, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp + ), + titleLarge = TextStyle( + fontFamily = Lexend, + fontWeight = FontWeight.SemiBold, + fontSize = IronLogType.title.fontSize.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontFamily = Lexend, + fontWeight = FontWeight.SemiBold, + fontSize = IronLogType.section.fontSize.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontFamily = Lexend, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + bodyLarge = TextStyle( + fontFamily = Lexend, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontFamily = Lexend, + fontWeight = FontWeight.Normal, + fontSize = IronLogType.body.fontSize.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontFamily = Lexend, + fontWeight = FontWeight.Normal, + fontSize = IronLogType.meta.fontSize.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp + ), + labelLarge = TextStyle( + fontFamily = Lexend, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontFamily = Lexend, + fontWeight = FontWeight.SemiBold, + fontSize = IronLogType.button.fontSize.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontFamily = Lexend, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) diff --git a/app/src/main/java/com/ironlog/app/ui/viewmodel/AppDataViewModel.kt b/app/src/main/java/com/ironlog/app/ui/viewmodel/AppDataViewModel.kt new file mode 100644 index 0000000..a4f3424 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/viewmodel/AppDataViewModel.kt @@ -0,0 +1,346 @@ +package com.ironlog.app.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ironlog.app.data.model.CreateCompletedWorkoutInput +import com.ironlog.app.data.model.FullPlanObject +import com.ironlog.app.data.model.PlanDayInput +import com.ironlog.app.data.repository.ExerciseRepository +import com.ironlog.app.data.repository.PlanRepository +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.data.repository.WorkoutRepository +import com.ironlog.app.ui.model.AppDataState +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.IronLogSettings +import com.ironlog.app.ui.model.UiPlan +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.json.JSONObject +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** Aggregates plans, history, settings, and personal bests for the entire app. */ +class AppDataViewModel( + private val plansVm: PlansViewModel = PlansViewModel(), + private val statsVm: StatsViewModel = StatsViewModel(), + private val planRepo: PlanRepository = PlanRepository(), + private val workoutRepo: WorkoutRepository = WorkoutRepository(), + private val exerciseRepo: ExerciseRepository = ExerciseRepository(), + private val settingsRepo: SettingsRepository = SettingsRepository(), +) : ViewModel() { + private val _state = MutableStateFlow(AppDataState()) + val state: StateFlow = _state.asStateFlow() + + private var refreshJob: Job? = null + private var manualPb: Map = emptyMap() + private var exerciseNotes: Map = emptyMap() + /** In-flight write guard: prevents concurrent refresh() calls from reading a stale DB value + * and overwriting the optimistic UI state set in updateSettingsAsync. */ + @Volatile private var settingsWriteCache: IronLogSettings? = null + + init { + observePlanMutations() + refresh() + } + + private fun observePlanMutations() = viewModelScope.launch { + launch { + // Pass the emitted plans directly so refresh() always uses the latest snapshot, + // not a potentially-stale re-read of plansVm.plans.value. + plansVm.plans.collect { latestPlans -> refresh(latestPlans) } + } + } + + fun refresh(plansOverride: List? = null): Job { + refreshJob?.cancel() + val job = viewModelScope.launch { + val plans = plansOverride ?: plansVm.plans.value + val stats = statsVm.state.value + val exercises = exerciseRepo.getExercisesSnapshot() + val settings = readIronLogSettings() + val onboarding = settingsRepo.getBoolean(ONBOARDING_KEY, false) + manualPb = readDoubleMap(settingsRepo.getString("ironlog_pb")) + exerciseNotes = readStringMap(settingsRepo.getString(EXERCISE_NOTES_KEY)) + if (!onboarding && plans.isNotEmpty()) settingsRepo.setBoolean(ONBOARDING_KEY, true) + // plansLoaded becomes true once plansVm has finished its initial DB read (!loading), + // or once we receive a non-empty override (plansVm emitted real data). + // An empty override list is the StateFlow's initial emission — NOT a real "loaded" signal. + // Sticky: once true, stays true so re-refreshes don't bounce the UI back to "loading". + val plansLoaded = _state.value.plansLoaded || + (plansOverride != null && plansOverride.isNotEmpty()) || + !plansVm.loading.value + _state.value = AppDataState( + initialized = true, + plansLoaded = plansLoaded, + plans = plans, + history = stats.history, + pb = stats.pb + manualPb, + exerciseNotes = exerciseNotes, + settings = settings, + onboardingComplete = onboarding || plans.isNotEmpty(), + exerciseIndex = exercises, + ) + } + refreshJob = job + return job + } + + suspend fun updateSettings(updates: IronLogSettings): IronLogSettings { + val next = updates.copy(weeklyGoalDays = normalizeWeeklyGoalDays(updates.weeklyGoalDays, _state.value.settings.weeklyGoalDays)) + settingsRepo.setString("ironlog_settings", next.toJson().toString(), "json") + flagDirty("settings_changed") + refresh() + return next + } + + fun updateSettingsAsync(updates: IronLogSettings) = viewModelScope.launch { + // Optimistic: update state immediately so the UI responds on this frame. + val normalized = updates.copy(weeklyGoalDays = normalizeWeeklyGoalDays(updates.weeklyGoalDays, _state.value.settings.weeklyGoalDays)) + // Cache before DB write — any concurrent refresh() triggered by observePlanMutations + // will read this value instead of the not-yet-written DB row, preventing the + // selection-reverts-to-previous-value bug. + settingsWriteCache = normalized + _state.value = _state.value.copy(settings = normalized) + // Persist and refresh (refresh will also read cache while write is in-flight) + updateSettings(updates) + // DB write complete — future refresh() calls can read straight from DB again. + settingsWriteCache = null + } + + fun addHistory(entry: HistoryEntry) = viewModelScope.launch { + workoutRepo.createCompletedWorkout( + CreateCompletedWorkoutInput( + name = entry.dayName ?: entry.name, + startedAt = com.ironlog.app.domain.gamification.parseHistoryInstant(entry.date)?.toEpochMilli() + ?: System.currentTimeMillis(), + durationSeconds = entry.duration, + rating = entry.rating, + notes = entry.summaryText.orEmpty(), + exerciseData = entry.exercises.map { ex -> + com.ironlog.app.data.model.CompletedExerciseInput( + exerciseId = ex.exerciseId, + name = ex.name, + primaryMuscles = ex.primaryMuscles, + primaryMuscle = ex.primaryMuscle, + equipment = ex.equipment, + category = ex.category, + supersetGroup = ex.supersetGroup, + note = ex.note, + sets = ex.sets.map { set -> + com.ironlog.app.data.model.SetInput( + weight = set.weight, + reps = set.reps, + type = set.type, + rpe = set.rpe, + rir = set.rir?.toDouble(), + restSeconds = set.restSeconds, + isWarmup = set.type == "warmup", + isDropset = set.type == "drop", + isAmrap = set.type == "amrap", + toFailure = set.type == "failure", + ) + }, + ) + }, + ), + ) + buildIndexUpdatesForSession(entry) + flagDirty("workout_completion") + refresh() + } + + fun updatePb(key: String, value: Double) = viewModelScope.launch { + manualPb = manualPb + (key to value) + settingsRepo.setString("ironlog_pb", JSONObject(manualPb).toString(), "json") + flagDirty("pb_changed") + refresh() + } + + fun clearPbs() = viewModelScope.launch { + manualPb = emptyMap() + settingsRepo.setString("ironlog_pb", "{}", "json") + flagDirty("pb_cleared") + refresh() + } + + /** FIXED: 34 — Clear all completed workout history */ + fun clearAllHistory() = viewModelScope.launch { + runCatching { workoutRepo.clearCompletedWorkouts() } + flagDirty("history_cleared") + refresh() + } + + fun saveExerciseNotes(exName: String, note: String) = viewModelScope.launch { + val key = exName.trim() + if (key.isBlank()) return@launch + exerciseNotes = exerciseNotes + (key to note) + settingsRepo.setString(EXERCISE_NOTES_KEY, JSONObject(exerciseNotes).toString(), "json") + flagDirty("notes_changed") + refresh() + } + + fun completeOnboarding(weeklyGoalDays: Int? = null) = viewModelScope.launch { + weeklyGoalDays?.let { updateSettings(_state.value.settings.copy(weeklyGoalDays = normalizeWeeklyGoalDays(it, _state.value.settings.weeklyGoalDays))) } + settingsRepo.setBoolean(ONBOARDING_KEY, true) + flagDirty("onboarding_changed") + refresh() + } + + fun resetOnboarding() = viewModelScope.launch { + settingsRepo.setBoolean(ONBOARDING_KEY, false) + flagDirty("onboarding_changed") + refresh() + } + + fun updatePlanDay(planId: String, dayId: String, patch: UiPlan) = viewModelScope.launch { + // Source hook rebuilt full plans and persisted them. Prefer repository-level update when possible. + val plan = _state.value.plans.firstOrNull { it.id == planId } ?: return@launch + val day = plan.days.firstOrNull { it.id == dayId } ?: return@launch + planRepo.updatePlanDay(dayId, PlanDayInput(name = day.name, color = day.color)) + refresh() + } + + fun isHeavy(name: String): Boolean = HEAVY.contains(name) || Regex("deadlift|squat|shrug|weighted pull|romanian|front squat|back squat", RegexOption.IGNORE_CASE).containsMatchIn(name) + + private suspend fun readIronLogSettings(): IronLogSettings { + // If a write is in-flight, return the cached (authoritative) value rather than reading + // the not-yet-committed DB row, which would cause UI selection flicker. + settingsWriteCache?.let { return it } + val raw = settingsRepo.getString("ironlog_settings") ?: return IronLogSettings() + return try { + val json = JSONObject(raw) + IronLogSettings( + theme = json.optString("theme", com.ironlog.app.ui.theme.IronLogThemes.DEFAULT_THEME), + weightUnit = json.optString("weightUnit", "kg"), + hapticFeedback = json.optBoolean("hapticFeedback", true), + effortTracking = json.optString("effortTracking", "off"), + defaultRestSeconds = json.optInt("defaultRestSeconds", 90), + defaultRestHeavySeconds = json.optInt("defaultRestHeavySeconds", 180), + barWeightKg = json.optDouble("barWeightKg", 20.0), + weeklyGoalDays = normalizeWeeklyGoalDays(json.optInt("weeklyGoalDays", 4), 4), + goalMode = json.optString("goalMode", "hypertrophy"), + progressionStyle = json.optString("progressionStyle", "balanced"), + userName = json.optString("userName", ""), + performanceMode = json.optString("performanceMode", "balanced"), + intelligenceMode = json.optString("intelligenceMode", "builtin"), + cloudAiBaseUrl = json.optString("cloudAiBaseUrl", ""), + cloudAiModelName = json.optString("cloudAiModelName", ""), + cloudAiDisplayName = json.optString("cloudAiDisplayName", ""), + cloudAiProviderPreset = json.optString("cloudAiProviderPreset", ""), + cloudAiApiFormat = json.optString("cloudAiApiFormat", "openai"), + ) + } catch (_: Throwable) { IronLogSettings() } + } + + private suspend fun buildIndexUpdatesForSession(session: HistoryEntry) { + if (session.exercises.isEmpty()) return + val prIndex = JSONObject(settingsRepo.getString("pr_index_v1") ?: "{}") + val volumeIndex = JSONObject(settingsRepo.getString("volume_index_v1") ?: "{}") + val lastPerf = JSONObject(settingsRepo.getString("last_performance_v1") ?: "{}") + val dateStr = session.date.substringBefore('T') + val weekKey = isoWeekKey(session.date) + session.exercises.forEach { ex -> + val exId = ex.exerciseId.ifBlank { ex.name } + val workingSets = ex.sets.filter { it.type.ifBlank { "normal" } != "warmup" } + lastPerf.put(exId, JSONObject().put("date", dateStr).put("workoutId", session.id)) + ex.sets.filter { it.type in setOf("normal", "failure", "amrap", "drop") }.forEach { set -> + val weight = set.weight + val reps = set.reps + if (weight > 0 && reps > 0) { + val arr = prIndex.optJSONArray(exId) ?: org.json.JSONArray().also { prIndex.put(exId, it) } + arr.put(JSONObject().put("date", dateStr).put("weight", weight).put("reps", reps).put("e1rm", kotlin.math.round(weight * (1 + reps / 30.0) * 10.0) / 10.0)) + } + } + if (workingSets.isNotEmpty()) { + val weekObj = volumeIndex.optJSONObject(weekKey) ?: JSONObject().also { volumeIndex.put(weekKey, it) } + val buckets = resolveSessionMuscleBuckets(ex) + buckets.forEach { muscle -> + val normalized = normalizeMuscleKey(muscle) + weekObj.put(normalized, weekObj.optInt(normalized, 0) + workingSets.size) + } + } + } + settingsRepo.setString("pr_index_v1", prIndex.toString(), "json") + settingsRepo.setString("volume_index_v1", volumeIndex.toString(), "json") + settingsRepo.setString("last_performance_v1", lastPerf.toString(), "json") + } + + private suspend fun flagDirty(reason: String) { + settingsRepo.setString("backup_dirty_reason", reason, "string") + settingsRepo.setString("backup_dirty_at", System.currentTimeMillis().toString(), "number") + } +} + +private const val EXERCISE_NOTES_KEY = "exercise_notes_v1" +private const val ONBOARDING_KEY = "onboarding_complete" + +private val HEAVY = setOf("Weighted Pull-Up", "Weighted Pull-Up or Lat Pulldown", "Romanian Deadlift", "Bulgarian Split Squat", "DB Shrugs", "Incline Smith Press", "Barbell Shrugs", "Deadlift", "Back Squat", "Front Squat") + +private fun normalizeWeeklyGoalDays(value: Int, fallback: Int = 4): Int = if (value in 1..7) value else fallback.coerceIn(1, 7) +private fun normalizeMuscleKey(value: String): String = value.trim().lowercase().replace(Regex("[_\\s]+"), "_") +private fun resolveSessionPrimaryMuscle(exercise: com.ironlog.app.ui.model.HistoryExercise): String = exercise.primaryMuscle ?: exercise.primaryMuscles.firstOrNull { it.isNotBlank() } ?: "other" +private fun resolveSessionMuscleBuckets(exercise: com.ironlog.app.ui.model.HistoryExercise): List { + val raw = buildList { + add(exercise.primaryMuscle.orEmpty()) + addAll(exercise.primaryMuscles) + add(exercise.name) + add(exercise.category.orEmpty()) + } + val buckets = linkedSetOf() + raw.forEach { token -> + val t = token.lowercase() + when { + t.contains("chest") || t.contains("tricep") || t.contains("shoulder") || t.contains("delt") || t.contains("press") -> buckets += "push" + t.contains("back") || t.contains("lat") || t.contains("bicep") || t.contains("row") || t.contains("pull") -> buckets += "pull" + t.contains("quad") || t.contains("hamstring") || t.contains("glute") || t.contains("calf") || t.contains("leg") || t.contains("squat") || t.contains("deadlift") || t.contains("hinge") -> buckets += "legs" + t.contains("core") || t.contains("abs") || t.contains("ab ") || t.contains("crunch") || t.contains("plank") -> buckets += "core" + } + } + if (buckets.isEmpty()) buckets += normalizeMuscleKey(resolveSessionPrimaryMuscle(exercise)) + return buckets.toList() +} + +private fun isoWeekKey(dateStr: String): String { + // Use tolerant parser so both ISO-8601 instants and YYYY-MM-DD bare dates work. + val date = com.ironlog.app.domain.gamification.parseHistoryInstant(dateStr) + ?.atZone(java.time.ZoneId.systemDefault())?.toLocalDate() + ?: java.time.LocalDate.now() + val fields = java.time.temporal.WeekFields.ISO + return "${date.get(fields.weekBasedYear())}-W${date.get(fields.weekOfWeekBasedYear()).toString().padStart(2, '0')}" +} + +private fun readDoubleMap(raw: String?): Map = try { + val json = JSONObject(raw ?: "{}") + json.keys().asSequence().associateWith { json.optDouble(it) } +} catch (_: Throwable) { emptyMap() } + +private fun readStringMap(raw: String?): Map = try { + val json = JSONObject(raw ?: "{}") + json.keys().asSequence().associateWith { json.optString(it) } +} catch (_: Throwable) { emptyMap() } + +private fun IronLogSettings.toJson(): JSONObject = JSONObject() + .put("theme", theme) + .put("weightUnit", weightUnit) + .put("hapticFeedback", hapticFeedback) + .put("effortTracking", effortTracking) + .put("defaultRestSeconds", defaultRestSeconds) + .put("defaultRestHeavySeconds", defaultRestHeavySeconds) + .put("barWeightKg", barWeightKg) + .put("weeklyGoalDays", weeklyGoalDays) + .put("goalMode", goalMode) + .put("progressionStyle", progressionStyle) + .put("userName", userName) + .put("performanceMode", performanceMode) + .put("intelligenceMode", intelligenceMode) + .put("cloudAiBaseUrl", cloudAiBaseUrl) + .put("cloudAiModelName", cloudAiModelName) + .put("cloudAiDisplayName", cloudAiDisplayName) + .put("cloudAiProviderPreset", cloudAiProviderPreset) + .put("cloudAiApiFormat", cloudAiApiFormat) + diff --git a/app/src/main/java/com/ironlog/app/ui/viewmodel/BodyMeasurementsViewModel.kt b/app/src/main/java/com/ironlog/app/ui/viewmodel/BodyMeasurementsViewModel.kt new file mode 100644 index 0000000..fdfba53 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/viewmodel/BodyMeasurementsViewModel.kt @@ -0,0 +1,85 @@ +package com.ironlog.app.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.ironlog.app.data.objectbox.BodyMeasurementEntity +import com.ironlog.app.data.repository.BodyMeasurementInput +import com.ironlog.app.data.repository.BodyMeasurementRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.time.Instant + +/** Tracks body measurement entries and history. */ +data class BodyMeasurementUiRow( + val id: String, + val measuredAt: Long, + val date: String, + val bodyweight: Double?, + val waist: Double?, + val chest: Double?, + val arm: Double?, + val thigh: Double?, + val notes: String, +) + +data class BodyWeightUiRow( + val id: String, + val weight: Double, + val date: String, +) + +data class BodyMeasurementsState( + val measurements: List = emptyList(), +) { + val bodyWeight: List + get() = measurements + .filter { it.bodyweight != null } + .map { BodyWeightUiRow(id = it.id, weight = it.bodyweight ?: 0.0, date = it.date) } +} + +class BodyMeasurementsViewModel( + private val repository: BodyMeasurementRepository, +) : ViewModel() { + private val _state = MutableStateFlow(BodyMeasurementsState()) + val state: StateFlow = _state.asStateFlow() + + init { + viewModelScope.launch { + repository.getBodyMeasurementsFlow().collect { rows -> + _state.value = BodyMeasurementsState(rows.map { it.toUiRow() }) + } + } + } + + fun add(input: BodyMeasurementInput) = viewModelScope.launch { repository.addBodyMeasurement(input) } + fun update(id: String, input: BodyMeasurementInput) = viewModelScope.launch { repository.updateBodyMeasurement(id, input) } + fun remove(id: String) = viewModelScope.launch { repository.deleteBodyMeasurement(id) } +} + +class BodyMeasurementsViewModelFactory( + private val repository: BodyMeasurementRepository = BodyMeasurementRepository(), +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(BodyMeasurementsViewModel::class.java)) { + return BodyMeasurementsViewModel(repository) as T + } + throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") + } +} + +private fun BodyMeasurementEntity.toUiRow(): BodyMeasurementUiRow = BodyMeasurementUiRow( + id = uid, + measuredAt = measuredAt, + date = Instant.ofEpochMilli(measuredAt).toString(), + bodyweight = bodyweight, + waist = waist, + chest = chest, + arm = arm, + thigh = thigh, + notes = notes, +) + diff --git a/app/src/main/java/com/ironlog/app/ui/viewmodel/ExerciseProgressViewModel.kt b/app/src/main/java/com/ironlog/app/ui/viewmodel/ExerciseProgressViewModel.kt new file mode 100644 index 0000000..7ca4c52 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/viewmodel/ExerciseProgressViewModel.kt @@ -0,0 +1,120 @@ +package com.ironlog.app.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.ironlog.app.data.objectbox.ExerciseEntity +import com.ironlog.app.data.objectbox.ExerciseEntity_ +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.data.objectbox.WorkoutEntity +import com.ironlog.app.data.objectbox.WorkoutEntity_ +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity_ +import com.ironlog.app.data.objectbox.WorkoutSetEntity +import com.ironlog.app.data.objectbox.WorkoutSetEntity_ +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.HistoryExercise +import com.ironlog.app.ui.model.HistoryExerciseSet +import com.ironlog.app.util.normalizeExerciseNameKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.Instant + +class ExerciseProgressViewModel( + private val exerciseName: String, +) : ViewModel() { + private val _history = MutableStateFlow>(emptyList()) + val history: StateFlow> = _history.asStateFlow() + + init { + refresh() + } + + fun refresh() = viewModelScope.launch { + _history.value = withContext(Dispatchers.IO) { + val workoutsBox = ObjectBox.store.boxFor(WorkoutEntity::class.java) + val wesBox = ObjectBox.store.boxFor(WorkoutExerciseEntity::class.java) + val setsBox = ObjectBox.store.boxFor(WorkoutSetEntity::class.java) + val exercisesBox = ObjectBox.store.boxFor(ExerciseEntity::class.java) + + val byName = exercisesBox.query(ExerciseEntity_.normalizedName.equal(normalizeExerciseNameKey(exerciseName))) + .build().use { it.findFirst() } + ?: exercisesBox.query(ExerciseEntity_.name.equal(exerciseName)) + .build().use { it.findFirst() } + ?: return@withContext emptyList() + val rows = wesBox.query(WorkoutExerciseEntity_.exerciseUid.equal(byName.uid)) + .order(WorkoutExerciseEntity_.orderIndex) + .build().use { it.find() } + val workoutIds = rows.map { it.workoutUid }.distinct().toTypedArray() + val completedWorkouts = if (workoutIds.isEmpty()) emptyMap() else { + workoutsBox.query( + WorkoutEntity_.uid.oneOf(workoutIds) + .and(WorkoutEntity_.status.equal("completed")) + ).build().use { it.find() }.associateBy { it.uid } + } + val completedRows = rows.filter { completedWorkouts.containsKey(it.workoutUid) } + val weIds = completedRows.map { it.uid } + val setsByWe = if (weIds.isEmpty()) emptyMap() else { + setsBox.query(WorkoutSetEntity_.workoutExerciseUid.oneOf(weIds.toTypedArray())).build().use { it.find() }.groupBy { it.workoutExerciseUid } + } + + completedRows.mapNotNull { we -> + val workout = completedWorkouts[we.workoutUid] ?: return@mapNotNull null + if (workout.startedAt <= 0L) return@mapNotNull null + val exercise = HistoryExercise( + id = we.uid, + exerciseId = we.exerciseUid, + name = byName.name, + primaryMuscle = byName.primaryMuscle, + equipment = byName.equipment, + category = byName.category, + supersetGroup = we.supersetGroup.ifBlank { null }, + note = we.notes.ifBlank { null }, + sets = setsByWe[we.uid].orEmpty().sortedBy { it.setIndex }.map { s -> + HistoryExerciseSet( + id = s.uid, + weight = s.weight, + reps = s.reps, + type = when { + s.isWarmup -> "warmup" + s.isDropset -> "drop" + s.isAmrap -> "amrap" + s.toFailure -> "failure" + else -> "normal" + }, + rpe = s.rpe, + rir = s.rir, + restSeconds = s.restSeconds, + ) + }, + ) + HistoryEntry( + id = workout.uid, + date = Instant.ofEpochMilli(workout.startedAt).toString(), + duration = workout.durationSeconds, + rating = workout.rating, + summaryText = workout.notes.ifBlank { null }, + name = workout.name, + imported = workout.imported, + exercises = listOf(exercise), + ) + }.sortedBy { it.date } + } + } +} + +class ExerciseProgressViewModelFactory( + private val exerciseName: String, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(ExerciseProgressViewModel::class.java)) { + return ExerciseProgressViewModel(exerciseName) as T + } + throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") + } +} diff --git a/app/src/main/java/com/ironlog/app/ui/viewmodel/GamificationViewModel.kt b/app/src/main/java/com/ironlog/app/ui/viewmodel/GamificationViewModel.kt new file mode 100644 index 0000000..92f124f --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/viewmodel/GamificationViewModel.kt @@ -0,0 +1,475 @@ +// app/src/main/java/com/ironlog/app/ui/viewmodel/GamificationViewModel.kt +package com.ironlog.app.ui.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.ironlog.app.assets.ForgeFoxExpression +import com.ironlog.app.data.objectbox.IronLedgerEventEntity +import com.ironlog.app.data.objectbox.IronLedgerEventEntity_ +import com.ironlog.app.data.objectbox.AthleteCalibrationEntity +import com.ironlog.app.data.objectbox.AthleteCalibrationEntity_ +import com.ironlog.app.data.objectbox.GamificationProfileEntity +import com.ironlog.app.data.objectbox.GamificationProfileEntity_ +import com.ironlog.app.data.objectbox.PlanEntity +import com.ironlog.app.data.objectbox.WorkoutImportProvenanceMigration +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.domain.badges.AppStats +import com.ironlog.app.domain.badges.BadgeDefinitions +import com.ironlog.app.domain.gamification.AthleteCalibration +import com.ironlog.app.domain.gamification.DailyProofStatus +import com.ironlog.app.domain.gamification.IronGrade +import com.ironlog.app.domain.gamification.IronGradeGate +import com.ironlog.app.domain.gamification.IronLedgerEngine +import com.ironlog.app.domain.gamification.IronLedgerSnapshot +import com.ironlog.app.domain.gamification.IronLedgerStats +import com.ironlog.app.domain.gamification.RpgStats +import com.ironlog.app.domain.gamification.StatEngine +import com.ironlog.app.domain.gamification.StreakEngine +import com.ironlog.app.domain.gamification.XpAction +import com.ironlog.app.domain.gamification.XpEngine +import com.ironlog.app.domain.gamification.buildDailyProofSummary +import com.ironlog.app.domain.gamification.dailyWorkoutStreakDays +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.widget.WidgetUpdateWorker +import io.objectbox.BoxStore +import org.json.JSONObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +data class XpLogEntry( + val kind: String, + val title: String, + val detail: String, + val xp: Int, +) + +data class GamificationUiState( + val level: Int = 1, + val xpInLevel: Long = 0L, + val xpForNextLevel: Long = 100L, + val totalXp: Long = 0L, + val rank: String = "E", + val dailyStreakDays: Int = 0, + val streakWeeks: Int = 0, + val stats: RpgStats = RpgStats(), + val activeTitle: String = "Ledger Initiate", + val unlockedBadges: List = emptyList(), + val latestBadgeTitle: String? = null, + val integrityScore: Double = 1.0, + val xpLogs: List = emptyList(), + val nextGradeLabel: String? = null, + val nextGradeGates: List = emptyList(), + val ledgerStats: IronLedgerStats = IronLedgerStats(), + val dailyProofStatus: DailyProofStatus = DailyProofStatus.SETUP, + val dailyProofHeadline: String = "Set your proof loop", + val dailyProofDetail: String = "", + val dailyProofPrimaryActionLabel: String = "Choose a program", + val dailyProofPrimaryRoute: String = "ProgramPicker", + val foxExpressionId: String = ForgeFoxExpression.Clipboard.id, +) + +internal fun reconciledLedgerXp( + cachedTotalXp: Long, + ledgerTotalXp: Long, +): Long = maxOf(cachedTotalXp, ledgerTotalXp) + +internal fun unlockedBadgesAfterGrade( + existingCsv: String, + currentGrade: IronGrade, +): List { + val existing = existingCsv.split(",").map { it.trim() }.filter { it.isNotBlank() } + val canonicalGradeBadges = IronGrade.entries + .filter { it != IronGrade.UNCALIBRATED && it.ordinal <= currentGrade.ordinal } + .map { it.label } + val existingNonGradeBadges = existing.filter { id -> + IronGrade.entries.none { it.label == id } + } + return (canonicalGradeBadges + existingNonGradeBadges).distinct() +} + +class GamificationViewModel( + application: Application, + private val boxStore: BoxStore, +) : AndroidViewModel(application) { + + private val xpEngine = XpEngine() + private val statEngine = StatEngine() + private val streakEngine = StreakEngine() + private val ledgerEngine = IronLedgerEngine() + private val settingsRepo = SettingsRepository() + + private val profileBox get() = boxStore.boxFor(GamificationProfileEntity::class.java) + private val calibrationBox get() = boxStore.boxFor(AthleteCalibrationEntity::class.java) + private val ledgerEventBox get() = boxStore.boxFor(IronLedgerEventEntity::class.java) + + private val _uiState = MutableStateFlow(GamificationUiState()) + val uiState: StateFlow = _uiState + + init { + loadProfile() + } + + private fun getOrCreateProfile(): GamificationProfileEntity { + WorkoutImportProvenanceMigration.run() + return profileBox.query(GamificationProfileEntity_.offlineUserId.equal("local")) + .build().use { it.findFirst() } + ?: GamificationProfileEntity(offlineUserId = "local") + .also { profileBox.put(it) } + } + + fun loadProfile() { + viewModelScope.launch(Dispatchers.IO) { + val profile = getOrCreateProfile() + val badges = profile.unlockedBadges.split(",").filter { it.isNotBlank() } + _uiState.value = GamificationUiState( + level = profile.level, + xpInLevel = profile.xpInLevel, + xpForNextLevel = xpEngine.xpForLevel(profile.level), + rank = profile.rank, + streakWeeks = profile.streakWeeks, + activeTitle = profile.activeTitle, + unlockedBadges = badges, + ) + } + } + + /** + * Award XP for an action. Persists to ObjectBox and refreshes UI state. + */ + fun awardXp(action: XpAction) { + viewModelScope.launch(Dispatchers.IO) { + val profile = getOrCreateProfile() + val gained = xpEngine.xpForAction(action) + profile.totalXp += gained + profile.weeklyXp += gained + profile.level = xpEngine.levelFromTotalXp(profile.totalXp) + profile.xpInLevel = xpEngine.xpInCurrentLevel(profile.totalXp) + profile.rank = xpEngine.rankForLevel(profile.level) + profileBox.put(profile) + // Update only the XP/level fields — preserve xpLogs, ledgerStats, etc. from refreshFromHistory + _uiState.update { current -> + current.copy( + level = profile.level, + xpInLevel = profile.xpInLevel, + xpForNextLevel = xpEngine.xpForLevel(profile.level), + totalXp = profile.totalXp, + rank = profile.rank, + ) + } + } + } + + /** + * Recompute streak, stats, and refresh profile from history. + * Call this after every workout completion. + */ + fun refreshFromHistory(history: List, weeklyGoal: Int) { + viewModelScope.launch(Dispatchers.IO) { + val profile = getOrCreateProfile() + + val recoveryCircuitCompletions: Map = runCatching { + Json.decodeFromString>(profile.makeupCompletionsJson) + }.getOrDefault(emptyMap()) + + profile.streakWeeks = streakEngine.computeStreakWeeks(history, weeklyGoal, recoveryCircuitCompletions) + val dailyStreakDays = dailyWorkoutStreakDays(history) + + // Stats + val stats = statEngine.compute(history, profile.streakWeeks, history.size) + profile.statsJson = Json.encodeToString(stats) + + val snapshot = ledgerEngine.rebuild( + history = history, + weeklyGoal = weeklyGoal, + calibration = readCalibration(weeklyGoal), + ) + val reconciledTotalXp = reconciledLedgerXp( + cachedTotalXp = profile.totalXp, + ledgerTotalXp = snapshot.totalXp, + ) + val reconciledLevel = ledgerEngine.levelFromTotalXp(reconciledTotalXp) + val reconciledXpInLevel = ledgerEngine.xpInCurrentLevel(reconciledTotalXp) + val reconciledXpForNextLevel = ledgerEngine.xpForLevel(reconciledLevel) + profile.totalXp = reconciledTotalXp + profile.level = reconciledLevel + profile.xpInLevel = reconciledXpInLevel + profile.rank = snapshot.grade.label + val settingsJson = currentSettingsJson() + val unlockedBadges = mergedUnlockedBadges( + existingCsv = profile.unlockedBadges, + currentGrade = snapshot.grade, + appBadges = evaluateAppBadges( + history = history, + snapshot = snapshot, + settingsJson = settingsJson, + ), + ) + profile.unlockedBadges = unlockedBadges.joinToString(",") + profile.activeTitle = activeTitleFor(unlockedBadges, snapshot.grade) + + profileBox.put(profile) + persistLedgerEvents(snapshot.events) + val badges = unlockedBadges + val dailyProof = buildDailyProofSummary( + history = history, + hasActivePlan = hasAnyPlan(), + activeWorkoutDayName = settingsRepo.getString("active_workout_day_name"), + readinessScore = computeReadinessScore(history), + ) + val bonusXp = (reconciledTotalXp - snapshot.totalXp).coerceAtLeast(0L) + val xpLogs = buildList { + if (bonusXp > 0L) { + add( + XpLogEntry( + kind = "bonus", + title = "Stored bonus XP", + detail = "XP earned outside canonical workout proof", + xp = bonusXp.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), + ) + ) + } + addAll( + snapshot.events.map { + XpLogEntry( + kind = it.kind, + title = it.title, + detail = it.detail, + xp = it.xp, + ) + } + ) + } + _uiState.value = GamificationUiState( + level = reconciledLevel, + xpInLevel = reconciledXpInLevel, + xpForNextLevel = reconciledXpForNextLevel, + totalXp = reconciledTotalXp, + rank = snapshot.grade.label, + dailyStreakDays = dailyStreakDays, + streakWeeks = profile.streakWeeks, + stats = toRpgStats(snapshot.stats), + activeTitle = profile.activeTitle, + unlockedBadges = badges, + latestBadgeTitle = badges.lastOrNull()?.let(::badgeTitleForId), + integrityScore = snapshot.integrityScore, + xpLogs = xpLogs, + nextGradeLabel = snapshot.nextGrade?.label, + nextGradeGates = snapshot.nextGradeGates, + ledgerStats = snapshot.stats, + dailyProofStatus = dailyProof.status, + dailyProofHeadline = dailyProof.headline, + dailyProofDetail = dailyProof.detail, + dailyProofPrimaryActionLabel = dailyProof.primaryActionLabel, + dailyProofPrimaryRoute = dailyProof.primaryRoute, + foxExpressionId = dailyProof.foxExpressionId, + ) + } + } + + /** + * Record a completed recovery circuit for [isoWeekKey] and award XP. + */ + fun completeRecoveryCircuit(isoWeekKey: String) { + viewModelScope.launch(Dispatchers.IO) { + val profile = getOrCreateProfile() + val recoveryCircuitCompletions: MutableMap = runCatching { + Json.decodeFromString>(profile.makeupCompletionsJson).toMutableMap() + }.getOrDefault(mutableMapOf()) + recoveryCircuitCompletions[isoWeekKey] = (recoveryCircuitCompletions[isoWeekKey] ?: 0) + 1 + profile.makeupCompletionsJson = Json.encodeToString(recoveryCircuitCompletions as Map) + profileBox.put(profile) + awardXp(XpAction.RECOVERY_CIRCUIT) + WidgetUpdateWorker.enqueueOneTime(getApplication()) + } + } + + private fun persistLedgerEvents(events: List) { + runCatching { + val box = ledgerEventBox + events.forEach { event -> + val eventId = "${event.sourceId}:${event.kind}" + val existing = box.query(IronLedgerEventEntity_.eventId.equal(eventId)) + .build().use { it.findFirst() } + val entity = existing ?: IronLedgerEventEntity() + entity.eventId = eventId + entity.sourceType = if (event.sourceId.contains(':')) "exercise" else "workout" + entity.sourceId = event.sourceId + entity.eventKind = event.kind + entity.occurredAt = com.ironlog.app.domain.gamification.parseHistoryInstant(event.occurredAt) + ?.toEpochMilli() ?: 0L + entity.xpDelta = event.xp + entity.trustScore = event.trust + entity.metadataJson = org.json.JSONObject() + .put("title", event.title) + .put("detail", event.detail) + .toString() + box.put(entity) + } + } + } + + private suspend fun readCalibration(weeklyGoal: Int): AthleteCalibration { + val entity = calibrationBox.query(AthleteCalibrationEntity_.offlineUserId.equal("local")) + .build().use { it.findFirst() } + suspend fun settingInt(key: String): Int = + runCatching { settingsRepo.getSettingString(key)?.toIntOrNull() ?: 0 }.getOrDefault(0) + val historicalTrainingDays = entity?.historicalTrainingDaysPerWeek?.takeIf { it in 1..7 } + ?: settingInt("baseline_historical_training_days_per_week").takeIf { it in 1..7 } + ?: 3 + return AthleteCalibration( + trainingAgeMonths = entity?.trainingAgeMonths ?: settingInt("baseline_training_age_months"), + historicalTrainingDaysPerWeek = historicalTrainingDays, + importedHistory = entity?.importedHistory ?: settingsRepo.getBoolean("ledger_imported_history", false), + weeklyGoalDays = entity?.weeklyGoalDays ?: weeklyGoal, + bodyweightKg = entity?.bodyweightKg ?: settingInt("baseline_bodyweight_kg").takeIf { it > 0 }?.toDouble(), + hasPastTraining = entity?.hasPastTraining ?: settingsRepo.getBoolean("baseline_has_past_training", false), + hasGymAccess = entity?.hasGymAccess ?: settingsRepo.getBoolean("baseline_has_gym_access", true), + baselinePushups = entity?.baselinePushups ?: settingInt("baseline_pushups"), + baselinePullups = entity?.baselinePullups ?: settingInt("baseline_pullups"), + baselineBenchKg = entity?.baselineBenchKg ?: settingInt("baseline_bench_kg"), + baselineLatPulldownKg = entity?.baselineLatPulldownKg ?: settingInt("baseline_lat_pulldown_kg"), + baselineMileRunSeconds = entity?.baselineMileRunSeconds ?: settingInt("baseline_mile_run_seconds"), + ) + } + + private fun toRpgStats(stats: IronLedgerStats): RpgStats = RpgStats( + str = stats.strength, + vit = stats.recovery, + end = stats.endurance, + agi = stats.agility, + wis = stats.discipline, + luk = stats.power, + ) + + private fun titleForGrade(grade: IronGrade): String = when (grade) { + IronGrade.UNCALIBRATED -> "Ledger Initiate" + IronGrade.GRAPHITE -> "Graphite Trainee" + IronGrade.IRON -> "Iron Regular" + IronGrade.STEEL -> "Steel Builder" + IronGrade.TITANIUM -> "Titanium Athlete" + IronGrade.OBSIDIAN -> "Obsidian Veteran" + IronGrade.IRIDIUM -> "Iridium Specialist" + IronGrade.AETHER -> "Aether Standard" + IronGrade.APEX -> "Apex Ledger" + } + + private suspend fun currentSettingsJson(): JSONObject = + runCatching { JSONObject(settingsRepo.getString("ironlog_settings") ?: "{}") } + .getOrDefault(JSONObject()) + + private fun hasAnyPlan(): Boolean = + boxStore.boxFor(PlanEntity::class.java).query().build().use { it.count() > 0 } + + private fun computeReadinessScore(history: List): Int? { + if (history.isEmpty()) return null + val readiness = com.ironlog.app.domain.intelligence.RecoveryReadinessEngine + .readinessByRegion( + history.sortedByDescending { com.ironlog.app.domain.gamification.parseHistoryInstant(it.date) }.take(60) + ) + return com.ironlog.app.domain.intelligence.RecoveryReadinessEngine + .score(readiness) + .score + } + + private fun evaluateAppBadges( + history: List, + snapshot: IronLedgerSnapshot, + settingsJson: JSONObject, + ): Set { + val totalVolumeKg = history.sumOf { it.volume } + val dayStreak = dailyWorkoutStreakDays(history) + val currentRank = legacyRankForBadge(snapshot.grade) + val daysSinceFirstWorkout = history.mapNotNull { com.ironlog.app.domain.gamification.parseHistoryInstant(it.date) } + .minOrNull() + ?.let { java.time.temporal.ChronoUnit.DAYS.between(it.atZone(java.time.ZoneId.systemDefault()).toLocalDate(), java.time.LocalDate.now()).toInt() } + ?: 0 + val createdPlan = hasAnyPlan() + val usedRestTimer = history.any { entry -> + entry.exercises.any { exercise -> + exercise.sets.any { set -> set.restSeconds > 0 } + } + } + val hasLoggedPr = snapshot.events.any { it.kind == "pr" } + val cloudAiActivated = settingsJson.optString("intelligenceMode") == "cloud_ai" || + settingsJson.optString("cloudAiBaseUrl").isNotBlank() + val goalMode = settingsJson.optString("goalMode").ifBlank { "hypertrophy" } + val prWorkoutIds = snapshot.events + .filter { it.kind == "pr" } + .map { it.sourceId.substringBefore(':') } + .toSet() + var consecutivePrStreak = 0 + for (workout in history.sortedByDescending { it.date }) { + if (workout.id in prWorkoutIds) consecutivePrStreak++ else break + } + + return BadgeDefinitions.evaluate( + AppStats( + totalWorkouts = history.size, + currentStreak = dayStreak, + daysSinceFirstWorkout = daysSinceFirstWorkout, + totalVolumeKg = totalVolumeKg, + hasLoggedPR = hasLoggedPr, + usedRestTimer = usedRestTimer, + createdPlan = createdPlan, + cloudAiActivated = cloudAiActivated, + goalModesUsed = setOf(goalMode), + currentRank = currentRank, + weeksConsistent = snapshot.qualifyingWeeks, + consecutiveProgressionWorkouts = consecutivePrStreak, + ) + ) + } + + private fun activeTitleFor(unlockedBadges: List, grade: IronGrade): String { + val latestNamedBadge = unlockedBadges + .lastOrNull { badgeId -> BadgeDefinitions.all.any { it.id == badgeId } } + ?.let(::badgeTitleForId) + return latestNamedBadge ?: titleForGrade(grade) + } + + private fun badgeTitleForId(id: String): String = + BadgeDefinitions.all.firstOrNull { it.id == id }?.title ?: id + + private fun legacyRankForBadge(grade: IronGrade): String = when (grade) { + IronGrade.APEX, IronGrade.AETHER, IronGrade.IRIDIUM, IronGrade.OBSIDIAN -> "S" + IronGrade.TITANIUM -> "A" + IronGrade.STEEL -> "B" + IronGrade.IRON -> "C" + IronGrade.GRAPHITE -> "D" + IronGrade.UNCALIBRATED -> "E" + } +} + +class GamificationViewModelFactory( + private val application: Application, + private val boxStore: BoxStore, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(GamificationViewModel::class.java)) { + return GamificationViewModel(application, boxStore) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} + +private fun mergedUnlockedBadges( + existingCsv: String, + currentGrade: IronGrade, + appBadges: Set, +): List { + val base = unlockedBadgesAfterGrade(existingCsv, currentGrade) + val nonGradeExisting = existingCsv.split(",") + .map { it.trim() } + .filter { it.isNotBlank() && IronGrade.entries.none { grade -> grade.label == it } } + val orderedAppBadges = BadgeDefinitions.all.map { it.id }.filter { it in appBadges } + return (base + nonGradeExisting + orderedAppBadges).distinct() +} diff --git a/app/src/main/java/com/ironlog/app/ui/viewmodel/PlansViewModel.kt b/app/src/main/java/com/ironlog/app/ui/viewmodel/PlansViewModel.kt new file mode 100644 index 0000000..6c00aee --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/viewmodel/PlansViewModel.kt @@ -0,0 +1,281 @@ +package com.ironlog.app.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ironlog.app.data.model.FullPlanDay +import com.ironlog.app.data.model.FullPlanObject +import com.ironlog.app.data.model.PlanExerciseInput +import com.ironlog.app.data.model.PlanInput +import com.ironlog.app.data.repository.ExerciseRepository +import com.ironlog.app.data.repository.PlanRepository +import com.ironlog.app.ui.model.UiPlan +import com.ironlog.app.ui.model.UiPlanDay +import com.ironlog.app.ui.model.UiPlanExercise +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** Manages training plan data including CRUD and program scheduling. */ +class PlansViewModel( + private val plansRepo: PlanRepository = PlanRepository(), + private val exerciseRepo: ExerciseRepository = ExerciseRepository(), +) : ViewModel() { + private val _plans = MutableStateFlow>(emptyList()) + val plans: StateFlow> = _plans.asStateFlow() + + private val _loading = MutableStateFlow(true) + val loading: StateFlow = _loading.asStateFlow() + + init { + observePlanChanges() + refresh() + } + + private fun observePlanChanges() { + viewModelScope.launch { + plansRepo.getPlansFlow().collectLatest { + _plans.value = assemblePlans() + _loading.value = false + } + } + } + + fun refresh() { + viewModelScope.launch { + _loading.value = true + _plans.value = assemblePlans() + _loading.value = false + } + } + + private suspend fun assemblePlans(): List = withContext(Dispatchers.IO) { + plansRepo.ensureActivePlanIfNeeded() + val planRows = plansRepo.getPlansFlowReplayOnce() + val exerciseIndex = exerciseRepo.getExercisesSnapshot().associateBy { it.id } + planRows.map { plan -> + val days = plansRepo.getPlanDaysSnapshot(plan.uid).sortedBy { it.orderIndex } + UiPlan( + id = plan.uid, + name = plan.name, + goal = plan.goal, + description = plan.description, + isActive = plan.isActive, + days = days.map { day -> + val peRows = plansRepo.getPlanExercisesSnapshot(day.uid).sortedBy { it.orderIndex } + UiPlanDay( + id = day.uid, + name = day.name, + color = day.color, + exercises = peRows.map { pe -> + val ex = exerciseIndex[pe.exerciseUid] + UiPlanExercise( + id = pe.uid, + exerciseId = pe.exerciseUid, + name = ex?.name ?: "Unknown", + sets = pe.sets, + reps = pe.reps, + restSeconds = pe.restSeconds, + supersetGroup = pe.supersetGroup, + isWarmup = pe.isWarmup, + notes = pe.notes, + ) + }, + ) + }, + ) + } + } + + fun addPlan(name: String) = viewModelScope.launch { + plansRepo.createPlan(PlanInput(name = name.trim(), goal = "General Fitness", description = "", isActive = false)) + refresh() + } + + fun renamePlan(planId: String, name: String) = viewModelScope.launch { + plansRepo.updatePlan(planId, PlanInput(name = name.trim())) + refresh() + } + + fun removePlan(planId: String) = viewModelScope.launch { + plansRepo.deletePlan(planId) + refresh() + } + + /** Deactivates all plans, then marks [planId] as active. */ + fun setActivePlan(planId: String) = viewModelScope.launch { + val all = plansRepo.getPlansFlowReplayOnce() + all.forEach { plan -> + val shouldBeActive = plan.uid == planId + if (plan.isActive != shouldBeActive) { + plansRepo.updatePlan(plan.uid, PlanInput(isActive = shouldBeActive)) + } + } + refresh() + } + + fun addDay(planId: String, name: String, color: String = "#FF4500") = viewModelScope.launch { + plansRepo.createPlanDay(planId, com.ironlog.app.data.model.PlanDayInput(name = name, color = color)) + refresh() + } + + /** + * Creates a new plan day and returns its generated ID. + * Unlike [addDay], this suspend function waits for the write to complete + * and returns the persisted day's UID, which callers need to immediately + * add exercises to the new day (e.g. deload day generation). + */ + suspend fun addDayAndGetId(planId: String, name: String, color: String = "#FF4500"): String = + withContext(Dispatchers.IO) { + val created = plansRepo.createPlanDay(planId, com.ironlog.app.data.model.PlanDayInput(name = name, color = color)) + created.uid + }.also { viewModelScope.launch { refresh() } } + + fun renameDay(dayId: String, name: String) = viewModelScope.launch { + plansRepo.updatePlanDay(dayId, com.ironlog.app.data.model.PlanDayInput(name = name)) + refresh() + } + + fun updateDayColor(dayId: String, color: String) = viewModelScope.launch { + plansRepo.updatePlanDay(dayId, com.ironlog.app.data.model.PlanDayInput(color = color)) + refresh() + } + + fun removeDay(dayId: String) = viewModelScope.launch { + plansRepo.deletePlanDay(dayId) + refresh() + } + + fun addExerciseToDay(dayId: String, exerciseId: String, meta: PlanExerciseInput = PlanExerciseInput()) = viewModelScope.launch { + plansRepo.addExerciseToPlanDay(dayId, PlanExerciseInput( + exerciseId = exerciseId, + sets = meta.sets ?: 3, + reps = meta.reps ?: "8-12", + restSeconds = meta.restSeconds ?: 90, + supersetGroup = meta.supersetGroup ?: "", + isWarmup = meta.isWarmup ?: false, + notes = meta.notes ?: "", + )) + refresh() + } + + fun updateExercise(planExerciseId: String, updates: PlanExerciseInput) = viewModelScope.launch { + plansRepo.updatePlanExercise(planExerciseId, updates) + refresh() + } + + fun removeExercise(planExerciseId: String) = viewModelScope.launch { + plansRepo.removePlanExercise(planExerciseId) + refresh() + } + + fun reorderExercises(dayId: String, orderedIds: List) = viewModelScope.launch { + plansRepo.reorderPlanExercises(dayId, orderedIds) + refresh() + } + + fun reorderPlans(orderedIds: List) = viewModelScope.launch { + plansRepo.reorderPlans(orderedIds) + refresh() + } + + fun importPlan(planObject: FullPlanObject) = viewModelScope.launch { + plansRepo.importFullPlan(planObject) + refresh() + } + + fun importPlans(planObjects: List) = viewModelScope.launch { + planObjects.forEach { plansRepo.importFullPlan(it) } + refresh() + } + + /** + * Import a plan decoded from a QR code payload. + * Converts the [UiPlan] to a [FullPlanObject] (no IDs, just names/settings) + * so the repository assigns fresh UUIDs and avoids ID collisions. + */ + fun importPlanFromQr(plan: UiPlan) = viewModelScope.launch { + val fullPlan = FullPlanObject( + name = "${plan.name} (imported)", + goal = plan.goal, + description = plan.description, + days = plan.days.map { day -> + FullPlanDay( + name = day.name, + color = day.color, + exercises = day.exercises.map { ex -> + PlanExerciseInput( + exerciseId = ex.exerciseId, + name = ex.name, + sets = ex.sets, + reps = ex.reps, + restSeconds = ex.restSeconds, + supersetGroup = ex.supersetGroup, + isWarmup = ex.isWarmup, + notes = ex.notes, + ) + }, + ) + }, + ) + plansRepo.importFullPlan(fullPlan) + refresh() + } + + // GAP-12: copy all exercises from one day to another day + fun copyDay(sourceDayId: String, targetDayId: String) = viewModelScope.launch { + val allPlans = _plans.value + val sourceDay = allPlans.flatMap { it.days }.firstOrNull { it.id == sourceDayId } ?: return@launch + allPlans.flatMap { it.days }.firstOrNull { it.id == targetDayId } ?: return@launch + sourceDay.exercises.forEach { ex -> + plansRepo.addExerciseToPlanDay( + targetDayId, + PlanExerciseInput( + exerciseId = ex.exerciseId, + sets = ex.sets, + reps = ex.reps, + restSeconds = ex.restSeconds, + supersetGroup = ex.supersetGroup, + isWarmup = ex.isWarmup, + notes = ex.notes, + ) + ) + } + refresh() + } + + // GAP-10: duplicate a plan (deep-copies all days + exercises, "(Copy)" name suffix, not active) + fun duplicatePlan(plan: UiPlan) = viewModelScope.launch { + val copy = FullPlanObject( + name = "${plan.name} (Copy)", + goal = plan.goal, + description = plan.description, + days = plan.days.map { day -> + FullPlanDay( + name = day.name, + color = day.color, + exercises = day.exercises.map { ex -> + PlanExerciseInput( + exerciseId = ex.exerciseId, + sets = ex.sets, + reps = ex.reps, + restSeconds = ex.restSeconds, + supersetGroup = ex.supersetGroup, + isWarmup = ex.isWarmup, + notes = ex.notes, + ) + }, + ) + }, + ) + plansRepo.importFullPlan(copy) + refresh() + } +} + +private suspend fun PlanRepository.getPlansFlowReplayOnce(): List = getPlansFlow().first() + diff --git a/app/src/main/java/com/ironlog/app/ui/viewmodel/StatsViewModel.kt b/app/src/main/java/com/ironlog/app/ui/viewmodel/StatsViewModel.kt new file mode 100644 index 0000000..e8cc344 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/ui/viewmodel/StatsViewModel.kt @@ -0,0 +1,251 @@ +package com.ironlog.app.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ironlog.app.data.objectbox.ExerciseEntity +import com.ironlog.app.data.objectbox.ExerciseEntity_ +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.data.objectbox.WorkoutEntity +import com.ironlog.app.data.objectbox.WorkoutEntity_ +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity_ +import com.ironlog.app.data.objectbox.WorkoutSetEntity +import com.ironlog.app.data.objectbox.WorkoutSetEntity_ +import com.ironlog.app.data.repository.SettingsRepository +import com.ironlog.app.domain.intelligence.PrLinkingEngine +import com.ironlog.app.ui.model.ChartPoint +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.HistoryExercise +import com.ironlog.app.ui.model.HistoryExerciseSet +import com.ironlog.app.ui.model.PersonalBest +import com.ironlog.app.ui.model.StatsUiState +import com.ironlog.app.domain.intelligence.ManualRecoveryInput +import com.ironlog.app.util.calculateEstimated1RM +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.math.roundToInt + +/** Provides statistics and analytics data for the Stats screen. */ +class StatsViewModel( + private val settingsRepo: SettingsRepository = SettingsRepository(), +) : ViewModel() { + private val _state = MutableStateFlow(StatsUiState()) + val state: StateFlow = _state.asStateFlow() + + private val _manualRecoveryInput = MutableStateFlow(null) + val manualRecoveryInput: StateFlow = _manualRecoveryInput.asStateFlow() + + init { + refresh() + viewModelScope.launch { + var lastSignature: String? = null + while (true) { + val signature = runCatching { + withContext(Dispatchers.IO) { + val workoutsBox = ObjectBox.store.boxFor(WorkoutEntity::class.java) + val completed = workoutsBox.query(WorkoutEntity_.status.equal("completed")) + .build().use { it.find() } + val latest = completed.maxOfOrNull { it.startedAt } ?: 0L + "${completed.size}:$latest" + } + }.getOrNull() + if (signature != null && signature != lastSignature) { + lastSignature = signature + refresh() + } + kotlinx.coroutines.delay(1200) + } + } + } + + fun refresh() = viewModelScope.launch { + _state.value = buildStats() + _manualRecoveryInput.value = loadManualRecovery() + } + + suspend fun clearPbs() { settingsRepo.setSetting("ironlog_pb", "{}", "json"); refresh() } + + suspend fun saveManualRecovery(input: ManualRecoveryInput) { + val stamped = input.copy(recordedAt = System.currentTimeMillis()) + val jsonString = kotlinx.serialization.json.Json.encodeToString(ManualRecoveryInput.serializer(), stamped) + settingsRepo.setSetting("manual_recovery_input", jsonString, "json") + _manualRecoveryInput.value = stamped + } + + private suspend fun loadManualRecovery(): ManualRecoveryInput? { + val raw = settingsRepo.getSettingString("manual_recovery_input") ?: return null + return runCatching { kotlinx.serialization.json.Json.decodeFromString(ManualRecoveryInput.serializer(), raw) }.getOrNull() + } + + private suspend fun buildStats(): StatsUiState = withContext(Dispatchers.IO) { + val workoutsBox = ObjectBox.store.boxFor(WorkoutEntity::class.java) + val wesBox = ObjectBox.store.boxFor(WorkoutExerciseEntity::class.java) + val setsBox = ObjectBox.store.boxFor(WorkoutSetEntity::class.java) + val exercisesBox = ObjectBox.store.boxFor(ExerciseEntity::class.java) + + val workouts = workoutsBox.query(WorkoutEntity_.status.equal("completed")) + .orderDesc(WorkoutEntity_.startedAt) + .build().use { it.find() } + val workoutIds = workouts.map { it.uid }.toTypedArray() + val wes = if (workoutIds.isEmpty()) emptyList() else { + wesBox.query(WorkoutExerciseEntity_.workoutUid.oneOf(workoutIds)) + .order(WorkoutExerciseEntity_.orderIndex) + .build().use { it.find() } + } + val weIds = wes.map { it.uid }.toTypedArray() + val sets = if (weIds.isEmpty()) emptyList() else { + setsBox.query(WorkoutSetEntity_.workoutExerciseUid.oneOf(weIds)) + .order(WorkoutSetEntity_.setIndex) + .build().use { it.find() } + } + val exerciseIds = wes.map { it.exerciseUid }.distinct().toTypedArray() + val exercises = if (exerciseIds.isEmpty()) emptyList() else { + exercisesBox.query(ExerciseEntity_.uid.oneOf(exerciseIds)) + .build().use { it.find() } + } + val weightUnit = parseWeightUnit(settingsRepo.getString("ironlog_settings")) + + val weByWorkout = wes.groupBy { it.workoutUid } + val setsByWe = sets.groupBy { it.workoutExerciseUid } + val exById = exercises.associateBy { it.uid } + + val history = workouts.map { w -> + val exRows = weByWorkout[w.uid].orEmpty().sortedBy { it.orderIndex } + HistoryEntry( + id = w.uid, + date = Instant.ofEpochMilli(w.startedAt).toString(), + duration = w.durationSeconds, + rating = w.rating, + summaryText = w.notes.ifBlank { null }, + name = w.name, + planDayUid = w.planDayUid?.ifBlank { null }, + imported = w.imported, + exercises = exRows.map { we -> + val ex = exById[we.exerciseUid] + HistoryExercise( + id = we.uid, + exerciseId = we.exerciseUid, + name = ex?.name ?: "Unknown", + primaryMuscle = ex?.primaryMuscle, + equipment = ex?.equipment, + category = ex?.category, + supersetGroup = we.supersetGroup.ifBlank { null }, + note = we.notes.ifBlank { null }, + sets = setsByWe[we.uid].orEmpty().sortedBy { it.setIndex }.map { s -> + HistoryExerciseSet( + id = s.uid, + weight = s.weight, + reps = s.reps, + type = when { + s.isWarmup -> "warmup" + s.isDropset -> "drop" + s.isAmrap -> "amrap" + s.toFailure -> "failure" + else -> "normal" + }, + rpe = s.rpe, + rir = s.rir?.roundToInt()?.toDouble(), + restSeconds = s.restSeconds, + ) + }, + ) + }, + ) + } + + val datestamps = history.map { it.date.substringBefore('T') } + val streak = buildStreak(datestamps) + val completedWeIds = wes.map { it.uid }.toSet() + val totalSets = sets.size + val avgDurationMin = if (workouts.isNotEmpty()) (workouts.sumOf { it.durationSeconds }.toDouble() / workouts.size / 60.0).roundToInt() else 0 + val chartData = build14DayChart(datestamps) + + // FIXED: 26 — track date + previousOrm per PB entry + val weToEx = wes.associate { it.uid to it.exerciseUid } + val weToWorkout = wes.associate { it.uid to it.workoutUid } + val workoutDateByUid = workouts.associate { it.uid to Instant.ofEpochMilli(it.startedAt).atZone(java.time.ZoneId.systemDefault()).toLocalDate().toString() } + // Data: (weight, bestOrm, exName, date, secondBestOrm) + val bestByExerciseUid = linkedMapOf>() + sets.forEach { set -> + if (set.workoutExerciseUid !in completedWeIds || set.isWarmup) return@forEach + val exerciseUid = weToEx[set.workoutExerciseUid] ?: return@forEach + val ex = exById[exerciseUid] ?: return@forEach + val orm = calculateEstimated1RM(set.weight, set.reps) + val date = workoutDateByUid[weToWorkout[set.workoutExerciseUid]] ?: "" + val prev = bestByExerciseUid[exerciseUid] + if (prev == null || orm > (prev[1] as Double)) { + val prevOrm = prev?.get(1) as? Double + bestByExerciseUid[exerciseUid] = arrayOf(set.weight, orm, ex.name, date, prevOrm) + } else { + // Track second-best if current is between prev and current best + val prevSecond = prev[4] as? Double + if (prevSecond == null || orm > prevSecond) { + prev[4] = orm + } + } + } + val personalBests = bestByExerciseUid.values + .map { PersonalBest(exerciseName = it[2] as String, bestWeight = it[0] as Double, estOneRm = it[1] as Double, date = it[3] as? String, previousOrm = it[4] as? Double) } + .sortedByDescending { it.estOneRm } + val pb = personalBests.associate { it.exerciseName to it.estOneRm } + val pbByGroup = linkedMapOf() + personalBests.forEach { best -> + val ex = exercises.firstOrNull { it.name == best.exerciseName } ?: return@forEach + val groupId = PrLinkingEngine.getPrGroupId(ex.name, ex.primaryMuscle, ex.equipment, ex.category, "safe") ?: "exact_${ex.name}" + val prev = pbByGroup[groupId] ?: Double.NEGATIVE_INFINITY + if (best.estOneRm > prev) pbByGroup[groupId] = best.estOneRm + } + + StatsUiState( + history = history, + pb = pb, + personalBests = personalBests, + pbGroups = pbByGroup, + streak = streak, + totalSets = totalSets, + avgDurationMin = avgDurationMin, + chartData = chartData, + weightUnit = weightUnit, + ) + } +} + +fun buildStreak(datestamps: List): Int { + if (datestamps.isEmpty()) return 0 + val days = datestamps.toSet().sortedDescending() + val today = LocalDate.now().toString() + val yesterday = LocalDate.now().minusDays(1).toString() + if (days.first() != today && days.first() != yesterday) return 0 + var streak = 0 + var prev = LocalDate.parse(days.first()) + for (d in days) { + val current = LocalDate.parse(d) + val diff = java.time.Duration.between(current.atStartOfDay(), prev.atStartOfDay()).toDays() + if (diff <= 1) { streak += 1; prev = current } else break + } + return streak +} + +fun build14DayChart(datestamps: List): List { + val counts = datestamps.groupingBy { it }.eachCount() + val labelIndices = setOf(0, 4, 9, 13) + val formatter = DateTimeFormatter.ofPattern("dd/MM") + return (0 until 14).map { i -> + val day = LocalDate.now().minusDays((13 - i).toLong()) + ChartPoint(value = counts[day.toString()] ?: 0, label = if (i in labelIndices) day.format(formatter) else "") + } +} + +private fun parseWeightUnit(raw: String?): String = try { + raw?.takeIf { it.isNotBlank() }?.let { JSONObject(it).optString("weightUnit", "kg") } ?: "kg" +} catch (_: Throwable) { "kg" } + diff --git a/app/src/main/java/com/ironlog/app/util/BodyWeightAnalytics.kt b/app/src/main/java/com/ironlog/app/util/BodyWeightAnalytics.kt new file mode 100644 index 0000000..886ca4b --- /dev/null +++ b/app/src/main/java/com/ironlog/app/util/BodyWeightAnalytics.kt @@ -0,0 +1,121 @@ +package com.ironlog.app.util + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import kotlin.math.ceil +import kotlin.math.roundToInt + +private const val MS_PER_DAY: Long = 24L * 60L * 60L * 1000L +val BODY_WEIGHT_RANGES = listOf("7D", "30D", "3M", "1Y", "All") +private val RANGE_DAYS = mapOf("7D" to 7, "30D" to 30, "3M" to 90, "1Y" to 365) + +data class RawBodyWeightEntry(val id: String? = null, val weight: Any? = null, val date: String? = null) +data class NormalizedBodyWeightEntry( + val id: String, + val weight: Double, + val timestamp: Long, + val date: String, + val sourceIndex: Int, +) +data class BodyWeightSummary( + val currentWeight: Double?, + val changeFromPrevious: Double?, + val weeklyTrend: Double?, + val weekChange: Double?, + val monthChange: Double?, + val totalChange: Double?, +) + +private fun toDayKey(timestamp: Long): String = Instant.ofEpochMilli(timestamp) + .atZone(ZoneId.systemDefault()).toLocalDate().toString() + +private fun average(values: List): Double? = if (values.isEmpty()) null else values.sum() / values.size + +private fun startOfIsoWeek(timestamp: Long): Long { + val zone = ZoneId.systemDefault() + val d = Instant.ofEpochMilli(timestamp).atZone(zone).toLocalDate() + val diff = if (d.dayOfWeek.value == 7) -6 else 1 - d.dayOfWeek.value + return d.plusDays(diff.toLong()).atStartOfDay(zone).toInstant().toEpochMilli() +} + +private fun startOfMonth(timestamp: Long): Long { + val zone = ZoneId.systemDefault() + val d = Instant.ofEpochMilli(timestamp).atZone(zone).toLocalDate().withDayOfMonth(1) + return d.atStartOfDay(zone).toInstant().toEpochMilli() +} + +private fun getAnchorTimestamp(entriesAsc: List): Long = + entriesAsc.lastOrNull()?.timestamp ?: System.currentTimeMillis() + +fun normalizeBodyWeightEntries(rawEntries: List?): List { + if (rawEntries == null) return emptyList() + return rawEntries.mapIndexedNotNull { sourceIndex, entry -> + val weight = when (val w = entry.weight) { + is Number -> w.toDouble() + is String -> w.toDoubleOrNull() + else -> null + } + val timestamp = entry.date?.let { runCatching { Instant.parse(it).toEpochMilli() }.getOrNull() } + if (weight == null || timestamp == null || !weight.isFinite()) null + else NormalizedBodyWeightEntry( + id = entry.id ?: "$timestamp-$sourceIndex", + weight = weight, + timestamp = timestamp, + date = entry.date, + sourceIndex = sourceIndex, + ) + }.sortedBy { it.timestamp } +} + +fun filterEntriesByRange(entriesAsc: List, range: String): List { + if (entriesAsc.isEmpty() || range == "All") return entriesAsc + val days = RANGE_DAYS[range] ?: return entriesAsc + // Anchored to now, matching the JS fix. + val cutoff = System.currentTimeMillis() - days * MS_PER_DAY + return entriesAsc.filter { it.timestamp >= cutoff } +} + +fun dedupeLatestEntryByDay(entriesAsc: List): List { + val byDay = LinkedHashMap() + entriesAsc.forEach { byDay[toDayKey(it.timestamp)] = it } + return byDay.values.sortedBy { it.timestamp } +} + +fun downsampleSeries(points: List, maxPoints: Int = 140): List { + if (points.size <= maxPoints) return points + val stride = (points.size - 1).toDouble() / (maxPoints - 1).toDouble() + return List(maxPoints) { i -> points[(i * stride).roundToInt().coerceIn(points.indices)] } +} + +fun calculateBodyWeightSummary( + allEntriesAsc: List, + selectedEntriesAsc: List, +): BodyWeightSummary { + val latestOverall = allEntriesAsc.lastOrNull() + val firstOverall = allEntriesAsc.firstOrNull() + val latestSelected = selectedEntriesAsc.lastOrNull() ?: latestOverall + val latestSelectedIndex = latestSelected?.let { ls -> allEntriesAsc.indexOfFirst { it.id == ls.id } } ?: -1 + val previousEntry = if (latestSelectedIndex > 0) allEntriesAsc[latestSelectedIndex - 1] else null + val anchorTs = latestSelected?.timestamp ?: getAnchorTimestamp(allEntriesAsc) + + fun getWindowAverage(windowStart: Long, windowEnd: Long): Double? { + return average(allEntriesAsc.filter { it.timestamp > windowStart && it.timestamp <= windowEnd }.map { it.weight }) + } + + val weekStart = startOfIsoWeek(anchorTs) + val monthStart = startOfMonth(anchorTs) + val weekEntries = allEntriesAsc.filter { it.timestamp >= weekStart && it.timestamp <= anchorTs } + val monthEntries = allEntriesAsc.filter { it.timestamp >= monthStart && it.timestamp <= anchorTs } + val recentAvg = getWindowAverage(anchorTs - 7 * MS_PER_DAY, anchorTs) + val previousAvg = getWindowAverage(anchorTs - 14 * MS_PER_DAY, anchorTs - 7 * MS_PER_DAY) + + return BodyWeightSummary( + currentWeight = latestSelected?.weight, + changeFromPrevious = if (latestSelected != null && previousEntry != null) latestSelected.weight - previousEntry.weight else null, + weeklyTrend = if (recentAvg != null && previousAvg != null) recentAvg - previousAvg else null, + weekChange = if (weekEntries.size >= 2) weekEntries.last().weight - weekEntries.first().weight else null, + monthChange = if (monthEntries.size >= 2) monthEntries.last().weight - monthEntries.first().weight else null, + totalChange = if (latestOverall != null && firstOverall != null) latestOverall.weight - firstOverall.weight else null, + ) +} diff --git a/app/src/main/java/com/ironlog/app/util/Dates.kt b/app/src/main/java/com/ironlog/app/util/Dates.kt new file mode 100644 index 0000000..a82d8a8 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/util/Dates.kt @@ -0,0 +1,33 @@ +package com.ironlog.app.util + +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.minus +import kotlinx.datetime.toLocalDateTime + +/** Mirrors JS startOfWeek() behavior as Monday-local week start. */ +fun startOfWeekMillis(tz: TimeZone = TimeZone.currentSystemDefault()): Long { + val today = Clock.System.now().toLocalDateTime(tz).date + // +7 before % 7 ensures the result is never negative (e.g. Sunday ordinal=6 gives 6 days back) + val daysFromMonday = (today.dayOfWeek.ordinal - DayOfWeek.MONDAY.ordinal + 7) % 7 + val monday = today.minus(daysFromMonday, DateTimeUnit.DAY) + return monday.atStartOfDayIn(tz).toEpochMilliseconds() +} + +/** Formats an epoch-millisecond timestamp as "dd MMM yyyy" (UK locale, e.g. "05 Jan 2025"). */ +fun formatHistoryDate(timestamp: Long): String { + val date = Instant.fromEpochMilliseconds(timestamp).toLocalDateTime(TimeZone.currentSystemDefault()).date + return "${date.dayOfMonth.toString().padStart(2, '0')} ${monthAbbr(date.month)} ${date.year}" +} + +/** Formats a LocalDate as "d MMM" for chart axis labels (e.g. "5 Jan"). */ +fun formatChartAxisDate(date: LocalDate): String = "${date.dayOfMonth} ${monthAbbr(date.month)}" + +private fun monthAbbr(month: Month): String = + month.name.lowercase().replaceFirstChar { it.uppercase() }.take(3) diff --git a/app/src/main/java/com/ironlog/app/util/ExerciseTrackingTypeNormalizer.kt b/app/src/main/java/com/ironlog/app/util/ExerciseTrackingTypeNormalizer.kt new file mode 100644 index 0000000..a75f271 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/util/ExerciseTrackingTypeNormalizer.kt @@ -0,0 +1,94 @@ +package com.ironlog.app.util + +import java.util.Locale + +object ExerciseTrackingTypeNormalizer { + private val validTrackingTypes = setOf("weight_reps", "duration", "duration_distance", "duration_weight") + + private val repBasedNameSignals = listOf( + Regex("\\bab crunch\\b"), + Regex("\\bcrunch(es)?\\b"), + Regex("\\bsit[- ]?up(s)?\\b"), + Regex("\\bleg pull[- ]?in\\b"), + Regex("\\bmountain climber(s)?\\b"), + Regex("\\bcurl(s)?\\b"), + Regex("\\braise(s)?\\b"), + Regex("\\bextension(s)?\\b"), + Regex("\\bpress(es)?\\b"), + Regex("\\brow\\b"), + Regex("\\bpulldown(s)?\\b"), + Regex("\\bpushdown(s)?\\b"), + Regex("\\bfly(es)?\\b"), + Regex("\\bsquat(s)?\\b"), + Regex("\\bdeadlift(s)?\\b"), + Regex("\\bhip thrust(s)?\\b"), + Regex("\\babduction(s)?\\b"), + Regex("\\badduction(s)?\\b"), + Regex("\\bdip(s)?\\b"), + Regex("\\bpull[- ]?up(s)?\\b"), + Regex("\\bpush[- ]?up(s)?\\b"), + Regex("\\blunge(s)?\\b"), + Regex("\\bshrug(s)?\\b"), + Regex("\\bcalf raise(s)?\\b"), + Regex("\\bbox jump(s)?\\b"), + ) + + private val durationOnlyNameSignals = listOf( + Regex("\\bplank(s)?\\b"), + Regex("\\bhold(s)?\\b"), + Regex("\\bstretch(es)?\\b"), + Regex("\\bmobility\\b"), + Regex("\\bisometric\\b"), + Regex("\\bdead hang\\b"), + Regex("\\bwall sit\\b"), + ) + + private val durationDistanceNameSignals = listOf( + Regex("\\btreadmill\\b"), + Regex("\\bwalk(ing)?\\b"), + Regex("\\brun(ning)?\\b"), + Regex("\\bsprint(s)?\\b"), + Regex("\\bbike\\b"), + Regex("\\bcycling\\b"), + Regex("\\bcycle\\b"), + Regex("\\brower\\b"), + Regex("\\browing\\b"), + Regex("\\berg\\b"), + Regex("\\bski\\b"), + Regex("\\bswim(ming)?\\b"), + Regex("\\bstair\\b"), + Regex("\\belliptical\\b"), + Regex("\\bprowler\\b"), + Regex("\\bsled\\b"), + Regex("\\bcarry\\b"), + Regex("\\bfarmer\\b"), + Regex("\\bjump rope\\b"), + Regex("\\bbattle rope\\b"), + ) + + fun normalize( + name: String?, + category: String?, + equipment: String?, + explicitTrackingType: String?, + ): String { + val normalizedName = name.norm() + val normalizedCategory = category.norm() + val normalizedEquipment = equipment.norm() + val explicit = explicitTrackingType.norm().takeIf { it in validTrackingTypes } + + if (durationOnlyNameSignals.any { it.containsMatchIn(normalizedName) }) return "duration" + if (repBasedNameSignals.any { it.containsMatchIn(normalizedName) }) return "weight_reps" + if (durationDistanceNameSignals.any { it.containsMatchIn(normalizedName) }) return "duration_distance" + + return when { + normalizedCategory.contains("cardio") || normalizedCategory.contains("conditioning") -> "duration_distance" + normalizedCategory.contains("mobility") || normalizedCategory.contains("stretch") -> "duration" + normalizedEquipment.contains("cardio") || normalizedEquipment.contains("conditioning") -> "duration_distance" + explicit != null -> explicit + else -> "weight_reps" + } + } + + private fun String?.norm(): String = orEmpty().trim().lowercase(Locale.ROOT) +} diff --git a/app/src/main/java/com/ironlog/app/util/ExerciseUiFilters.kt b/app/src/main/java/com/ironlog/app/util/ExerciseUiFilters.kt new file mode 100644 index 0000000..18f91a4 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/util/ExerciseUiFilters.kt @@ -0,0 +1,102 @@ +package com.ironlog.app.util + +import com.ironlog.app.data.model.LegacyExerciseShape +import java.util.Locale + +private fun norm(value: String?): String = value.orEmpty() + .replace(Regex("([a-z])([A-Z])"), "$1 $2") + .lowercase(Locale.US) + .replace(Regex("[^a-z0-9]+"), " ") + .trim() + +fun getExerciseFilterSummary(exercise: LegacyExerciseShape, maxItems: Int = 6): String { + val muscles = buildList { + addAll(exercise.primaryMuscles) + addAll(exercise.secondaryMuscles) + exercise.primaryMuscle?.let { add(it) } + }.map { it.trim() }.filter { it.isNotBlank() }.distinct() + return muscles.take(maxItems).joinToString(" · ") +} + +fun matchesExerciseFilter(exercise: LegacyExerciseShape, filter: String?): Boolean { + val f = norm(filter) + if (f.isBlank()) return true + val haystack = listOf( + exercise.name, + exercise.primaryMuscle.orEmpty(), + exercise.primaryMuscles.joinToString(" "), + exercise.secondaryMuscles.joinToString(" "), + exercise.equipment, + exercise.category, + exercise.movementPattern.orEmpty(), + exercise.difficulty.orEmpty(), + exercise.equipmentDetail.orEmpty(), + ).joinToString(" ").let(::norm) + return haystack.contains(f) +} + +fun buildFilterChipOptions(exercises: List, includeCategory: Boolean = true, includeEquipment: Boolean = true): List { + val set = linkedSetOf() + exercises.forEach { ex -> + ex.primaryMuscles.forEach { if (it.isNotBlank()) set += it } + ex.primaryMuscle?.takeIf { it.isNotBlank() }?.let { set += it } + ex.secondaryMuscles.forEach { if (it.isNotBlank()) set += it } + if (includeEquipment && ex.equipment.isNotBlank()) set += ex.equipment + if (includeCategory && ex.category.isNotBlank()) set += ex.category.replaceFirstChar { it.titlecase(Locale.US) } + } + return set.sorted() +} + +fun queryExerciseSearch(exercises: List, query: String): List { + val q = norm(query) + if (q.isBlank()) return exercises + + // Split into individual words; only keep words ≥2 chars to avoid noise + val queryWords = q.split(" ").filter { it.length >= 2 } + + return exercises + .map { ex -> + val name = norm(ex.name) + val aliases = ex.aliases.joinToString(" ").let(::norm) + val haystack = listOf( + ex.name, + ex.primaryMuscle.orEmpty(), + ex.primaryMuscles.joinToString(" "), + ex.secondaryMuscles.joinToString(" "), + ex.equipment, + ex.category, + ex.movementPattern.orEmpty(), + ex.difficulty.orEmpty(), + ).joinToString(" ").let(::norm) + + val score = when { + // Exact full-name match + name == q -> 0 + // Name starts with the full query + name.startsWith(q) -> 1 + // Name contains the full query as a substring + name.contains(q) -> 2 + // Alias contains full query + aliases.contains(q) -> 2 + // All query words found in the name (handles "bench press" → "Incline Bench Press") + queryWords.isNotEmpty() && queryWords.all { w -> name.contains(w) } -> 3 + // Any single word in the query starts a word in the name + // e.g. "squa" → "squat", "benchp" → no, but "curl" → "Bicep Curl" + queryWords.isNotEmpty() && queryWords.any { w -> + name.split(" ").any { namePart -> namePart.startsWith(w) && w.length >= 3 } + } -> 4 + // All query words found anywhere in the full metadata haystack + queryWords.isNotEmpty() && queryWords.all { w -> haystack.contains(w) } -> 5 + // Any query word ≥3 chars found in haystack (broadest catch-all) + queryWords.any { w -> w.length >= 3 && haystack.contains(w) } -> 6 + else -> 1000 + } + score to ex + } + .filter { it.first < 1000 } + .sortedWith(compareBy> { it.first }.thenBy { it.second.name }) + .map { it.second } +} + +fun normalizeExerciseNameKey(name: String): String = + name.trim().lowercase(Locale.US).replace(Regex("[^a-z0-9]+"), "_").trimEnd('_') diff --git a/app/src/main/java/com/ironlog/app/util/FuzzyExerciseMapper.kt b/app/src/main/java/com/ironlog/app/util/FuzzyExerciseMapper.kt new file mode 100644 index 0000000..4082fd6 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/util/FuzzyExerciseMapper.kt @@ -0,0 +1,50 @@ +package com.ironlog.app.util + +import com.ironlog.app.data.objectbox.ExerciseEntity + +class FuzzyExerciseMapper(exercises: List) { + private val normMap = mutableMapOf() + private val lib = exercises.map { it to it.name.lowercase().replace(Regex("[^a-z0-9]"), "") } + + fun match(rawName: String): String? { + val n = rawName.lowercase().replace(Regex("[^a-z0-9]"), "") + if (normMap.containsKey(n)) return normMap[n] + + val exact = lib.find { it.second == n } + if (exact != null) { + normMap[n] = exact.first.uid + return exact.first.uid + } + + var best: ExerciseEntity? = null + var bestSim = 0.0 + for ((ex, norm) in lib) { + val dist = levenshteinDistance(n, norm) + if (dist > 3) continue + val sim = 1.0 - (dist.toDouble() / maxOf(n.length, norm.length, 1)) + if (sim >= 0.8 && sim > bestSim) { + bestSim = sim + best = ex + } + } + if (best != null) { + normMap[n] = best.uid + return best.uid + } + + return null + } + + private fun levenshteinDistance(a: String, b: String): Int { + val dp = Array(a.length + 1) { IntArray(b.length + 1) } + for (i in 0..a.length) dp[i][0] = i + for (j in 0..b.length) dp[0][j] = j + for (i in 1..a.length) { + for (j in 1..b.length) { + val cost = if (a[i - 1] == b[j - 1]) 0 else 1 + dp[i][j] = minOf(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost) + } + } + return dp[a.length][b.length] + } +} diff --git a/app/src/main/java/com/ironlog/app/util/HapticsEngine.kt b/app/src/main/java/com/ironlog/app/util/HapticsEngine.kt new file mode 100644 index 0000000..2eb9f01 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/util/HapticsEngine.kt @@ -0,0 +1,140 @@ +package com.ironlog.app.util + +import android.content.Context +import android.content.ContextWrapper +import android.app.Activity +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.view.HapticFeedbackConstants + +object HapticsEngine { + private fun findActivity(context: Context): Activity? { + var current: Context? = context + repeat(12) { + when (current) { + is Activity -> return current + is ContextWrapper -> current = current.baseContext + else -> return null + } + } + return null + } + + private fun vibrator(context: Context): Vibrator? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager + vm?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + } + + @Suppress("DEPRECATION") + private fun pulse( + context: Context, + predefinedEffect: Int, + fallbackDurationMs: Long, + fallbackAmplitude: Int = VibrationEffect.DEFAULT_AMPLITUDE, + viewFeedbackConstant: Int, + ) { + // First path: UI haptic feedback (works better on some OEM builds). + runCatching { + val activity = findActivity(context) + val view = activity?.window?.decorView + if (view != null) { + view.performHapticFeedback(viewFeedbackConstant, HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING) + } + } + + val vib = vibrator(context) ?: return + if (!vib.hasVibrator()) return + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Second path: platform predefined vibration. + val ok = runCatching { + vib.vibrate(VibrationEffect.createPredefined(predefinedEffect)) + }.isSuccess + if (!ok) { + runCatching { + vib.vibrate(VibrationEffect.createOneShot(fallbackDurationMs, fallbackAmplitude)) + } + } + return + } + + runCatching { vib.vibrate(fallbackDurationMs) } + } + + fun selection(context: Context) = pulse( + context = context, + predefinedEffect = VibrationEffect.EFFECT_TICK, + fallbackDurationMs = 12L, + viewFeedbackConstant = HapticFeedbackConstants.CLOCK_TICK, + ) + + fun lightConfirm(context: Context) = pulse( + context = context, + predefinedEffect = VibrationEffect.EFFECT_CLICK, + fallbackDurationMs = 18L, + viewFeedbackConstant = HapticFeedbackConstants.KEYBOARD_TAP, + ) + + fun success(context: Context) = pulse( + context = context, + predefinedEffect = VibrationEffect.EFFECT_HEAVY_CLICK, + fallbackDurationMs = 28L, + fallbackAmplitude = 180, + viewFeedbackConstant = HapticFeedbackConstants.CONFIRM, + ) + + fun error(context: Context) = pulse( + context = context, + predefinedEffect = VibrationEffect.EFFECT_DOUBLE_CLICK, + fallbackDurationMs = 36L, + fallbackAmplitude = 220, + viewFeedbackConstant = HapticFeedbackConstants.REJECT, + ) + + fun low(context: Context) = pulse( + context = context, + predefinedEffect = VibrationEffect.EFFECT_TICK, + fallbackDurationMs = 10L, + fallbackAmplitude = 90, + viewFeedbackConstant = HapticFeedbackConstants.CLOCK_TICK, + ) + + fun medium(context: Context) = pulse( + context = context, + predefinedEffect = VibrationEffect.EFFECT_CLICK, + fallbackDurationMs = 18L, + fallbackAmplitude = 140, + viewFeedbackConstant = HapticFeedbackConstants.KEYBOARD_TAP, + ) + + fun mediumStrong(context: Context) = pulse( + context = context, + predefinedEffect = VibrationEffect.EFFECT_HEAVY_CLICK, + fallbackDurationMs = 26L, + fallbackAmplitude = 180, + viewFeedbackConstant = HapticFeedbackConstants.CONFIRM, + ) + + fun strong(context: Context) = pulse( + context = context, + predefinedEffect = VibrationEffect.EFFECT_DOUBLE_CLICK, + fallbackDurationMs = 42L, + fallbackAmplitude = 255, + viewFeedbackConstant = HapticFeedbackConstants.REJECT, + ) + + fun toggle(context: Context, enabled: Boolean) = pulse( + context = context, + predefinedEffect = if (enabled) VibrationEffect.EFFECT_CLICK else VibrationEffect.EFFECT_TICK, + fallbackDurationMs = if (enabled) 16L else 10L, + fallbackAmplitude = if (enabled) 130 else 100, + viewFeedbackConstant = if (enabled) HapticFeedbackConstants.TOGGLE_ON else HapticFeedbackConstants.TOGGLE_OFF, + ) +} diff --git a/app/src/main/java/com/ironlog/app/util/Metrics.kt b/app/src/main/java/com/ironlog/app/util/Metrics.kt new file mode 100644 index 0000000..b21f0b7 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/util/Metrics.kt @@ -0,0 +1,15 @@ +package com.ironlog.app.util + +import kotlin.math.max + +/** Direct port of calculateSetVolume(weight, reps). */ +fun calculateSetVolume(weight: Number?, reps: Number?): Double = + (weight?.toDouble() ?: 0.0) * (reps?.toDouble() ?: 0.0) + +/** Epley-style estimated 1RM used by the RN stats layer. */ +fun calculateEstimated1RM(weight: Number?, reps: Number?): Double { + val w = max(0.0, weight?.toDouble() ?: 0.0) + val r = max(0.0, reps?.toDouble() ?: 0.0) + if (w <= 0.0 || r <= 0.0) return 0.0 + return if (r <= 1.0) w else w * (1.0 + r / 30.0) +} diff --git a/app/src/main/java/com/ironlog/app/util/PlateCalc.kt b/app/src/main/java/com/ironlog/app/util/PlateCalc.kt new file mode 100644 index 0000000..ee7f505 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/util/PlateCalc.kt @@ -0,0 +1,39 @@ +package com.ironlog.app.util + +import com.ironlog.app.ui.screens.settings.PlateDto +import kotlin.math.round + +data class PlateCalculationResult( + val isValid: Boolean, + val platesPerSide: List, + val totalWeightKg: Double +) + +fun calculatePlates( + targetWeightKg: Double, + barWeightKg: Double, + inventory: List +): PlateCalculationResult { + val perSide = (targetWeightKg - barWeightKg) / 2.0 + if (perSide <= 0) return PlateCalculationResult(false, emptyList(), targetWeightKg) + + // Sort inventory from heaviest to lightest + val available = inventory.sortedByDescending { it.weightKg } + val used = mutableListOf() + + var remaining = round(perSide * 100.0) / 100.0 + + for (plate in available) { + var count = 0 + while (remaining >= plate.weightKg - 0.001 && count < plate.quantity) { + count++ + remaining = round((remaining - plate.weightKg) * 100.0) / 100.0 + } + if (count > 0) { + used.add(PlateDto(plate.weightKg, count)) + } + } + + val isValid = remaining <= 0.001 + return PlateCalculationResult(isValid, used, targetWeightKg) +} diff --git a/app/src/main/java/com/ironlog/app/util/Validation.kt b/app/src/main/java/com/ironlog/app/util/Validation.kt new file mode 100644 index 0000000..a7eb351 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/util/Validation.kt @@ -0,0 +1,29 @@ +package com.ironlog.app.util + +fun requireNonEmpty(value: Any?, fieldName: String) { + val ok = when (value) { + null -> false + is String -> value.trim().isNotEmpty() + else -> value.toString().trim().isNotEmpty() + } + if (!ok) error("$fieldName is required.") +} + +fun requireNumberMin(value: Number?, min: Double, fieldName: String) { + val number = value?.toDouble() ?: Double.NaN + if (!number.isFinite() || number < min) error("$fieldName must be at least $min.") +} + +fun validateContributionFraction(value: Number?) { + val number = value?.toDouble() ?: Double.NaN + if (!number.isFinite() || number < 0.0 || number > 1.0) { + error("contribution_fraction must be between 0 and 1.") + } +} + +fun normalizeExerciseName(value: String?): String = value.orEmpty().trim().replace(Regex("\\s+"), " ") +fun normalizeExerciseNameKey(value: String?): String = normalizeExerciseName(value).lowercase() + +fun jsTruthyString(value: String?): Boolean = value != null && value.isNotEmpty() +fun jsNumberOrZero(value: Number?): Double = value?.toDouble() ?: 0.0 +fun jsBoolean(value: Boolean?): Boolean = value == true diff --git a/app/src/main/java/com/ironlog/app/util/WeightUnits.kt b/app/src/main/java/com/ironlog/app/util/WeightUnits.kt new file mode 100644 index 0000000..5ed23b5 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/util/WeightUnits.kt @@ -0,0 +1,35 @@ +package com.ironlog.app.util + +import java.util.Locale +import kotlin.math.round + +private const val KG_TO_LBS = 2.20462262185 + +fun convertKgToUnit(weightKg: Double, unit: String = "kg", decimals: Int = if (unit == "lbs") 0 else 1): Double { + val converted = if (unit.lowercase(Locale.US) == "lbs") weightKg * KG_TO_LBS else weightKg + val scale = Math.pow(10.0, decimals.coerceAtLeast(0).toDouble()) + return round(converted * scale) / scale +} + +fun convertUnitToKg(value: Double, unit: String = "kg", decimals: Int = 3): Double { + val kg = if (unit.lowercase(Locale.US) == "lbs") value / KG_TO_LBS else value + val scale = Math.pow(10.0, decimals.coerceAtLeast(0).toDouble()) + return round(kg * scale) / scale +} + +fun formatWeightFromKg(weightKg: Double, unit: String = "kg"): String { + val value = convertKgToUnit(weightKg, unit, if (unit == "lbs") 0 else 1) + val clean = if (value % 1.0 == 0.0) value.toInt().toString() else String.format(Locale.US, "%.1f", value) + return "$clean ${if (unit.lowercase(Locale.US) == "lbs") "lb" else "kg"}" +} + +fun formatVolumeFromKg(volumeKg: Double, unit: String = "kg"): String = formatWeightFromKg(volumeKg, unit) + +fun epley(weight: Double, reps: Double): Double = if (weight > 0.0 && reps > 0.0) weight * (1.0 + reps / 30.0) else 0.0 + +fun formatDurationShort(secondsValue: Number?): String { + val total = kotlin.math.max(0, kotlin.math.round((secondsValue?.toDouble() ?: 0.0)).toInt()) + val mm = (total / 60).toString().padStart(2, '0') + val ss = (total % 60).toString().padStart(2, '0') + return "$mm:$ss" +} diff --git a/app/src/main/java/com/ironlog/app/widget/BadgeWidget.kt b/app/src/main/java/com/ironlog/app/widget/BadgeWidget.kt new file mode 100644 index 0000000..305ea4f --- /dev/null +++ b/app/src/main/java/com/ironlog/app/widget/BadgeWidget.kt @@ -0,0 +1,36 @@ +package com.ironlog.app.widget + +import android.content.Context +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import androidx.glance.currentState + +class BadgeWidget : GlanceAppWidget() { + override val stateDefinition = WidgetStateDefinition + override val sizeMode: SizeMode = SizeMode.Responsive( + setOf( + DpSize(110.dp, 110.dp), + DpSize(180.dp, 180.dp), + DpSize(220.dp, 320.dp), + DpSize(320.dp, 160.dp), + ) + ) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + ForgeFoxWidgetContent( + state = currentState(), + preferredLayout = ForgeWidgetLayoutClass.SMALL, + ) + } + } +} + +class BadgeWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = BadgeWidget() +} diff --git a/app/src/main/java/com/ironlog/app/widget/DashboardWidget.kt b/app/src/main/java/com/ironlog/app/widget/DashboardWidget.kt new file mode 100644 index 0000000..5574df3 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/widget/DashboardWidget.kt @@ -0,0 +1,36 @@ +package com.ironlog.app.widget + +import android.content.Context +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import androidx.glance.currentState + +class DashboardWidget : GlanceAppWidget() { + override val stateDefinition = WidgetStateDefinition + override val sizeMode: SizeMode = SizeMode.Responsive( + setOf( + DpSize(110.dp, 110.dp), + DpSize(180.dp, 180.dp), + DpSize(220.dp, 320.dp), + DpSize(320.dp, 160.dp), + ) + ) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + ForgeFoxWidgetContent( + state = currentState(), + preferredLayout = ForgeWidgetLayoutClass.WIDE, + ) + } + } +} + +class DashboardWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = DashboardWidget() +} diff --git a/app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetAssets.kt b/app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetAssets.kt new file mode 100644 index 0000000..075f380 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetAssets.kt @@ -0,0 +1,23 @@ +package com.ironlog.app.widget + +import androidx.annotation.DrawableRes +import com.ironlog.app.R + +object ForgeFoxWidgetAssets { + @DrawableRes + val streakIcon: Int = R.drawable.ic_forge_streak_dumbbell + + @DrawableRes + fun mascotFor(state: WidgetVisualState): Int = when (state) { + WidgetVisualState.ACTIVE_STREAK -> R.drawable.forgefox_widget_active + WidgetVisualState.AT_RISK -> R.drawable.forgefox_widget_at_risk + WidgetVisualState.WORKOUT_SOON -> R.drawable.forgefox_widget_workout_soon + WidgetVisualState.WORKOUT_DONE -> R.drawable.forgefox_widget_done + WidgetVisualState.RECOVERY_DAY -> R.drawable.forgefox_widget_recovery + WidgetVisualState.EARLY_WORKOUT -> R.drawable.forgefox_widget_early_workout + WidgetVisualState.NO_EXCUSES -> R.drawable.forgefox_widget_coach + WidgetVisualState.NEW_PB -> R.drawable.forgefox_widget_pb + WidgetVisualState.COMEBACK -> R.drawable.forgefox_widget_comeback + WidgetVisualState.MISSION_COMPLETE -> R.drawable.forgefox_widget_mission_complete + } +} diff --git a/app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetContent.kt b/app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetContent.kt new file mode 100644 index 0000000..58241e9 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetContent.kt @@ -0,0 +1,305 @@ +package com.ironlog.app.widget + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.clickable +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import com.ironlog.app.MainActivity + +internal enum class ForgeWidgetLayoutClass { + SMALL, + MEDIUM, + TALL, + WIDE, +} + +@Composable +internal fun ForgeFoxWidgetContent( + state: WidgetState, + preferredLayout: ForgeWidgetLayoutClass, +) { + val context = LocalContext.current + val layout = rememberForgeWidgetLayoutClass(preferredLayout) + val presentation = forgePresentationFor(state.visualState, state.minutesUntilWorkout) + val route = state.primaryActionRoute.ifBlank { "Home" } + val modifier = GlanceModifier + .fillMaxSize() + .background(ColorProvider(presentation.background)) + .cornerRadius(if (layout == ForgeWidgetLayoutClass.SMALL) 18.dp else 22.dp) + .clickable( + actionStartActivity( + Intent(context, MainActivity::class.java) + .putExtra("ironlog_route", route) + ) + ) + .padding(if (layout == ForgeWidgetLayoutClass.SMALL) 10.dp else 14.dp) + + when (layout) { + ForgeWidgetLayoutClass.SMALL -> SmallForgeWidget(state, presentation, modifier) + ForgeWidgetLayoutClass.MEDIUM -> MediumForgeWidget(state, presentation, modifier) + ForgeWidgetLayoutClass.TALL -> TallForgeWidget(state, presentation, modifier) + ForgeWidgetLayoutClass.WIDE -> WideForgeWidget(state, presentation, modifier) + } +} + +@Composable +private fun rememberForgeWidgetLayoutClass(preferredLayout: ForgeWidgetLayoutClass): ForgeWidgetLayoutClass { + val size = LocalSize.current + return resolveForgeWidgetLayoutClass(size.width.value, size.height.value, preferredLayout) +} + +@Composable +private fun SmallForgeWidget( + state: WidgetState, + presentation: ForgeWidgetPresentation, + modifier: GlanceModifier, +) { + Column(modifier = modifier, horizontalAlignment = Alignment.Start) { + StreakLockup(state.dailyStreakDays, iconSize = 30.dp, numberSize = 34) + Text( + text = presentation.title, + maxLines = 1, + style = TextStyle( + color = ColorProvider(Color.White), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ), + ) + Spacer(GlanceModifier.height(2.dp)) + Image( + provider = ImageProvider(ForgeFoxWidgetAssets.mascotFor(state.visualState)), + contentDescription = presentation.title, + modifier = GlanceModifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } +} + +@Composable +private fun MediumForgeWidget( + state: WidgetState, + presentation: ForgeWidgetPresentation, + modifier: GlanceModifier, +) { + Column(modifier = modifier, horizontalAlignment = Alignment.Start) { + StreakLockup(state.dailyStreakDays, iconSize = 38.dp, numberSize = 44) + Spacer(GlanceModifier.height(4.dp)) + Row(modifier = GlanceModifier.fillMaxWidth().height(112.dp), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = GlanceModifier.width(88.dp)) { + Text( + text = presentation.title, + maxLines = 3, + style = TextStyle( + color = ColorProvider(Color.White), + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + ), + ) + Spacer(GlanceModifier.height(4.dp)) + Text( + text = presentation.message, + maxLines = 2, + style = TextStyle( + color = ColorProvider(presentation.accent), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ), + ) + } + Image( + provider = ImageProvider(ForgeFoxWidgetAssets.mascotFor(state.visualState)), + contentDescription = presentation.title, + modifier = GlanceModifier.fillMaxWidth().fillMaxHeight(), + contentScale = ContentScale.Fit, + ) + } + } +} + +@Composable +private fun TallForgeWidget( + state: WidgetState, + presentation: ForgeWidgetPresentation, + modifier: GlanceModifier, +) { + Column(modifier = modifier, horizontalAlignment = Alignment.Start) { + StreakLockup(state.dailyStreakDays, iconSize = 44.dp, numberSize = 52) + Text( + text = presentation.title, + maxLines = 2, + style = TextStyle( + color = ColorProvider(Color.White), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ), + ) + Text( + text = presentation.message, + maxLines = 2, + style = TextStyle( + color = ColorProvider(presentation.accent), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ), + ) + Spacer(GlanceModifier.height(4.dp)) + Image( + provider = ImageProvider(ForgeFoxWidgetAssets.mascotFor(state.visualState)), + contentDescription = presentation.title, + modifier = GlanceModifier.fillMaxWidth().height(142.dp), + contentScale = ContentScale.Fit, + ) + Spacer(GlanceModifier.height(6.dp)) + WeeklyProgressBand(state, presentation, compact = false) + } +} + +@Composable +private fun WideForgeWidget( + state: WidgetState, + presentation: ForgeWidgetPresentation, + modifier: GlanceModifier, +) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Column(modifier = GlanceModifier.width(164.dp)) { + StreakLockup(state.dailyStreakDays, iconSize = 38.dp, numberSize = 46) + Text( + text = presentation.title, + maxLines = 2, + style = TextStyle( + color = ColorProvider(Color.White), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ), + ) + Text( + text = presentation.message, + maxLines = 1, + style = TextStyle( + color = ColorProvider(presentation.accent), + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + ), + ) + } + Spacer(GlanceModifier.width(12.dp)) + Image( + provider = ImageProvider(ForgeFoxWidgetAssets.mascotFor(state.visualState)), + contentDescription = presentation.title, + modifier = GlanceModifier.fillMaxWidth().fillMaxHeight(), + contentScale = ContentScale.Fit, + ) + } +} + +@Composable +private fun StreakLockup(streakDays: Int, iconSize: Dp, numberSize: Int) { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + provider = ImageProvider(ForgeFoxWidgetAssets.streakIcon), + contentDescription = "Streak", + modifier = GlanceModifier.width(iconSize).height(iconSize), + contentScale = ContentScale.Fit, + ) + Spacer(GlanceModifier.width(8.dp)) + Text( + text = streakDays.coerceAtLeast(0).toString(), + maxLines = 1, + style = TextStyle( + color = ColorProvider(Color.White), + fontSize = numberSize.sp, + fontWeight = FontWeight.Bold, + ), + ) + } +} + +@Composable +private fun WeeklyProgressBand( + state: WidgetState, + presentation: ForgeWidgetPresentation, + compact: Boolean, +) { + Box( + modifier = GlanceModifier + .fillMaxWidth() + .cornerRadius(14.dp) + .background(ColorProvider(Color(0x22000000))) + .padding( + vertical = if (compact) 4.dp else 6.dp, + horizontal = if (compact) 4.dp else 6.dp, + ) + ) { + WeeklyProgressRow(state, presentation, compact) + } +} + +@Composable +private fun WeeklyProgressRow( + state: WidgetState, + presentation: ForgeWidgetPresentation, + compact: Boolean, +) { + val days = listOf("M", "T", "W", "T", "F", "S", "S") + val completion = if (state.weeklyCompletion.size >= 7) state.weeklyCompletion.take(7) + else List(7) { index -> index < state.weekSessionsCount.coerceIn(0, 7) } + val dotSize = if (compact) 10.dp else 16.dp + val daySize = if (compact) 8 else 10 + + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + days.forEachIndexed { index, day -> + Column( + modifier = GlanceModifier.width(if (compact) 17.dp else 27.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = day, + maxLines = 1, + style = TextStyle( + color = ColorProvider(Color(0xCCFFFFFF)), + fontSize = daySize.sp, + fontWeight = FontWeight.Bold, + ), + ) + Box( + modifier = GlanceModifier + .width(dotSize) + .height(dotSize) + .cornerRadius(99.dp) + .background( + ColorProvider( + if (completion[index]) presentation.accent else Color(0x33FFFFFF) + ) + ) + ) {} + } + } + } +} diff --git a/app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetPresentation.kt b/app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetPresentation.kt new file mode 100644 index 0000000..45c3267 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetPresentation.kt @@ -0,0 +1,93 @@ +package com.ironlog.app.widget + +import androidx.compose.ui.graphics.Color + +internal data class ForgeWidgetPresentation( + val title: String, + val message: String, + val background: Color, + val accent: Color, +) + +internal fun resolveForgeWidgetLayoutClass( + width: Float, + height: Float, + preferredLayout: ForgeWidgetLayoutClass, +): ForgeWidgetLayoutClass { + if (!width.isFinite() || !height.isFinite() || width <= 0f || height <= 0f) { + return preferredLayout + } + + return when { + width >= 240f && width > height * 1.28f -> ForgeWidgetLayoutClass.WIDE + height >= 240f && height > width * 1.12f -> ForgeWidgetLayoutClass.TALL + width <= 150f || height <= 150f -> ForgeWidgetLayoutClass.SMALL + else -> ForgeWidgetLayoutClass.MEDIUM + } +} + +internal fun forgePresentationFor( + visualState: WidgetVisualState, + minutesUntilWorkout: Int?, +): ForgeWidgetPresentation = when (visualState) { + WidgetVisualState.ACTIVE_STREAK -> ForgeWidgetPresentation( + title = "DAY STREAK", + message = "Keep it up!", + background = Color(0xFFFF3D00), + accent = Color(0xFFFFD166), + ) + WidgetVisualState.AT_RISK -> ForgeWidgetPresentation( + title = "AT RISK", + message = "Don't let it break!", + background = Color(0xFFC91616), + accent = Color(0xFFFF9A42), + ) + WidgetVisualState.WORKOUT_SOON -> ForgeWidgetPresentation( + title = "WORKOUT SOON", + message = minutesUntilWorkout?.let { "Starts in ${it.coerceAtLeast(0)} min" } ?: "I'm waiting...", + background = Color(0xFF5E1FB4), + accent = Color(0xFFFFB24A), + ) + WidgetVisualState.WORKOUT_DONE -> ForgeWidgetPresentation( + title = "WORKOUT DONE!", + message = "Streak saved", + background = Color(0xFF118A3B), + accent = Color(0xFF95F27C), + ) + WidgetVisualState.RECOVERY_DAY -> ForgeWidgetPresentation( + title = "RECOVERY DAY", + message = "Rest and recover", + background = Color(0xFF0B4C91), + accent = Color(0xFF8ED6FF), + ) + WidgetVisualState.EARLY_WORKOUT -> ForgeWidgetPresentation( + title = "EARLY WORKOUT?", + message = "Start your workout!", + background = Color(0xFF7A1FC8), + accent = Color(0xFFFFB24A), + ) + WidgetVisualState.NO_EXCUSES -> ForgeWidgetPresentation( + title = "NO EXCUSES.", + message = "Start your workout", + background = Color(0xFF111111), + accent = Color(0xFFFF6A00), + ) + WidgetVisualState.NEW_PB -> ForgeWidgetPresentation( + title = "NEW PB!", + message = "Strong work", + background = Color(0xFF0E7F42), + accent = Color(0xFFFFD34D), + ) + WidgetVisualState.COMEBACK -> ForgeWidgetPresentation( + title = "BACK ON TRACK!", + message = "Keep moving", + background = Color(0xFFFF9F0A), + accent = Color(0xFF4A2300), + ) + WidgetVisualState.MISSION_COMPLETE -> ForgeWidgetPresentation( + title = "MISSION COMPLETE", + message = "Workout done!", + background = Color(0xFFD99A13), + accent = Color(0xFFFFFFFF), + ) +} diff --git a/app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetSampleStates.kt b/app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetSampleStates.kt new file mode 100644 index 0000000..c7c7e63 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetSampleStates.kt @@ -0,0 +1,40 @@ +package com.ironlog.app.widget + +internal object ForgeFoxWidgetSampleStates { + val all: List = WidgetVisualState.entries.mapIndexed { index, visualState -> + WidgetState( + dailyStreakDays = when (visualState) { + WidgetVisualState.AT_RISK -> 210 + WidgetVisualState.EARLY_WORKOUT -> 196 + WidgetVisualState.NEW_PB -> 35 + else -> 23 + index + }, + weeklyGoal = 4, + weekSessionsCount = if (visualState == WidgetVisualState.MISSION_COMPLETE) 4 else 3, + weeklyCompletion = listOf(true, true, true, visualState == WidgetVisualState.MISSION_COMPLETE, false, false, false), + todayCompleted = visualState in setOf( + WidgetVisualState.WORKOUT_DONE, + WidgetVisualState.NEW_PB, + WidgetVisualState.COMEBACK, + WidgetVisualState.MISSION_COMPLETE, + ), + minutesUntilWorkout = if (visualState in setOf( + WidgetVisualState.WORKOUT_SOON, + WidgetVisualState.EARLY_WORKOUT, + ) + ) 30 else null, + isAtRisk = visualState == WidgetVisualState.AT_RISK, + isRecoveryDay = visualState == WidgetVisualState.RECOVERY_DAY, + hasNewPb = visualState == WidgetVisualState.NEW_PB, + visualState = visualState, + primaryActionRoute = when (visualState) { + WidgetVisualState.RECOVERY_DAY -> "RecoveryMap" + WidgetVisualState.WORKOUT_DONE, + WidgetVisualState.NEW_PB, + WidgetVisualState.COMEBACK, + WidgetVisualState.MISSION_COMPLETE -> "statusWindow" + else -> "Home" + }, + ) + } +} diff --git a/app/src/main/java/com/ironlog/app/widget/WarRoomWidget.kt b/app/src/main/java/com/ironlog/app/widget/WarRoomWidget.kt new file mode 100644 index 0000000..efe403c --- /dev/null +++ b/app/src/main/java/com/ironlog/app/widget/WarRoomWidget.kt @@ -0,0 +1,39 @@ +package com.ironlog.app.widget + +import android.content.Context +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import androidx.glance.currentState + +/** + * Backward-compatible receiver class name; user-facing content is now Forge Fox. + */ +class WarRoomWidget : GlanceAppWidget() { + override val stateDefinition = WidgetStateDefinition + override val sizeMode: SizeMode = SizeMode.Responsive( + setOf( + DpSize(110.dp, 110.dp), + DpSize(180.dp, 180.dp), + DpSize(220.dp, 320.dp), + DpSize(320.dp, 160.dp), + ) + ) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + ForgeFoxWidgetContent( + state = currentState(), + preferredLayout = ForgeWidgetLayoutClass.TALL, + ) + } + } +} + +class WarRoomWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = WarRoomWidget() +} diff --git a/app/src/main/java/com/ironlog/app/widget/WidgetDataRepository.kt b/app/src/main/java/com/ironlog/app/widget/WidgetDataRepository.kt new file mode 100644 index 0000000..8cc2677 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/widget/WidgetDataRepository.kt @@ -0,0 +1,294 @@ +package com.ironlog.app.widget + +import android.content.Context +import com.ironlog.app.data.objectbox.AthleteCalibrationEntity +import com.ironlog.app.data.objectbox.AthleteCalibrationEntity_ +import com.ironlog.app.data.objectbox.AppSettingEntity +import com.ironlog.app.data.objectbox.ExerciseEntity +import com.ironlog.app.data.objectbox.ExerciseEntity_ +import com.ironlog.app.data.objectbox.GamificationProfileEntity +import com.ironlog.app.data.objectbox.PlanDayEntity +import com.ironlog.app.data.objectbox.PlanDayEntity_ +import com.ironlog.app.data.objectbox.PlanEntity +import com.ironlog.app.data.objectbox.PlanEntity_ +import com.ironlog.app.data.objectbox.PlanExerciseEntity +import com.ironlog.app.data.objectbox.PlanExerciseEntity_ +import com.ironlog.app.domain.gamification.AthleteCalibration +import com.ironlog.app.domain.gamification.DailyProofStatus +import com.ironlog.app.domain.gamification.IronLedgerEngine +import com.ironlog.app.domain.gamification.buildDailyProofSummary +import com.ironlog.app.domain.gamification.dailyWorkoutStreakDays +import com.ironlog.app.domain.gamification.parseHistoryLocalDate +import com.ironlog.app.domain.intelligence.RecoveryReadinessEngine +import com.ironlog.app.domain.intelligence.WorkoutSuggestionEngine +import com.ironlog.app.ui.model.HistoryEntry +import io.objectbox.BoxStore +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.time.temporal.TemporalAdjusters +import java.time.temporal.WeekFields + +/** + * Canonical widget snapshot builder using Iron Ledger as source of truth. + */ +class WidgetDataRepository( + @Suppress("UNUSED_PARAMETER") context: Context, + private val boxStore: BoxStore, +) { + private val ledgerEngine = IronLedgerEngine() + private val suggestionEngine = WorkoutSuggestionEngine() + + fun buildWidgetState( + history: List, + weeklyGoal: Int, + ): WidgetState { + val weeklyGoalSafe = weeklyGoal.coerceAtLeast(1) + val calibration = readCalibration(weeklyGoalSafe) + val snapshot = ledgerEngine.rebuild( + history = history, + weeklyGoal = weeklyGoalSafe, + calibration = calibration, + ) + + // Reconcile: stored profile may have bonus XP from recovery circuits or other + // awardXp() calls that aren't captured by the history-only ledger rebuild. + val storedProfileXp = boxStore.boxFor(GamificationProfileEntity::class.java) + .query().build().use { it.findFirst() }?.totalXp ?: 0L + val reconciledXp = maxOf(storedProfileXp, snapshot.totalXp) + val reconciledLevel = ledgerEngine.levelFromTotalXp(reconciledXp) + val reconciledXpInLevel = ledgerEngine.xpInCurrentLevel(reconciledXp) + val reconciledXpForNextLevel = ledgerEngine.xpForLevel(reconciledLevel) + + val recentHistory = history.sortedByDescending { parseHistoryLocalDate(it.date) }.take(60) + val (recommendedDayName, recommendedDayBlurb) = buildRecommendation(recentHistory) + val weekSessions = countIsoWeekSessions(history) + val readinessScore = RecoveryReadinessEngine.score( + RecoveryReadinessEngine.readinessByRegion(recentHistory) + ).score + val latestBadgeTitle = readLatestBadgeTitle(snapshot.grade.label) + val dailyProof = buildDailyProofSummary( + history = history, + hasActivePlan = recommendedDayName != "No Plan", + activeWorkoutDayName = readActiveWorkoutDayName(), + readinessScore = readinessScore, + ) + val nowMs = System.currentTimeMillis() + val localNow = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(nowMs), ZoneId.systemDefault()) + val today = localNow.toLocalDate() + val historyDates = history.mapNotNull { parseHistoryLocalDate(it.date) }.toSet() + val weekStart = today.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) + val weeklyCompletion = buildWeeklyCompletion(historyDates, weekStart) + val todayCompleted = today in historyDates + val dailyStreakDays = dailyWorkoutStreakDays(history, nowMs) + val previousWorkoutDate = historyDates.filter { it.isBefore(today) }.maxOrNull() + val returnedAfterGap = todayCompleted && + previousWorkoutDate?.let { ChronoUnit.DAYS.between(it, today) >= 2 } == true + val reminderMinutesOfDay = readDailyReminderMinutesOfDay() + val notificationsEnabled = readSettingString("notifications_enabled") + ?.equals("true", ignoreCase = true) ?: false + val minutesUntilWorkout = if (!todayCompleted && notificationsEnabled && reminderMinutesOfDay != null) { + minutesUntilScheduledTime(localNow, reminderMinutesOfDay) + } else null + val scheduledWorkoutHour = reminderMinutesOfDay?.div(60) + val isAtRisk = dailyProof.status == DailyProofStatus.AT_RISK || + (dailyStreakDays > 0 && !todayCompleted && localNow.hour >= 18) + val isRecoveryDay = dailyProof.status == DailyProofStatus.RECOVER_SMART + val hasNewPb = isRecentWidgetEvent( + eventEpochMs = readSettingString("widget_last_new_pb_ms")?.toLongOrNull(), + nowEpochMs = nowMs, + maxAgeHours = 24, + ) + val visualState = resolveWidgetVisualState( + WidgetVisualInputs( + streakDays = dailyStreakDays, + todayCompleted = todayCompleted, + weekSessionsCount = weekSessions, + weeklyGoal = weeklyGoalSafe, + minutesUntilWorkout = minutesUntilWorkout, + scheduledWorkoutHour = scheduledWorkoutHour, + isAtRisk = isAtRisk, + isRecoveryDay = isRecoveryDay, + hasRecentNewPb = hasNewPb, + returnedAfterGap = returnedAfterGap, + ) + ) + + return WidgetState( + grade = snapshot.grade.label, + level = reconciledLevel, + title = titleForGrade(snapshot.grade.label), + dailyStreakDays = dailyStreakDays, + streakWeeks = snapshot.qualifyingWeeks, + integrityScore = snapshot.integrityScore, + totalXp = reconciledXp, + xpInLevel = reconciledXpInLevel, + xpForNextLevel = reconciledXpForNextLevel, + signalStrength = snapshot.stats.strength, + signalPower = snapshot.stats.power, + signalHypertrophy = snapshot.stats.hypertrophy, + signalEndurance = snapshot.stats.endurance, + signalAgility = snapshot.stats.agility, + signalDiscipline = snapshot.stats.discipline, + signalRecovery = snapshot.stats.recovery, + recommendedDayName = recommendedDayName, + recommendedDayBlurb = recommendedDayBlurb, + weekSessionsCount = weekSessions, + weeklyGoal = weeklyGoalSafe, + nextGradeLabel = snapshot.nextGrade?.label ?: "Apex", + nextGradeOutstanding = snapshot.nextGradeGates.count { !it.met }, + dailyProofHeadline = dailyProof.headline, + dailyProofDetail = dailyProof.detail, + primaryActionLabel = dailyProof.primaryActionLabel, + foxExpressionId = dailyProof.foxExpressionId, + latestBadgeTitle = latestBadgeTitle, + weeklyCompletion = weeklyCompletion, + todayCompleted = todayCompleted, + minutesUntilWorkout = minutesUntilWorkout, + isAtRisk = isAtRisk, + isRecoveryDay = isRecoveryDay, + hasNewPb = hasNewPb, + visualState = visualState, + primaryActionRoute = routeForVisualState(visualState, dailyProof.primaryRoute), + lastUpdatedEpochMs = nowMs, + ) + } + + private fun readCalibration(weeklyGoal: Int): AthleteCalibration { + val box = boxStore.boxFor(AthleteCalibrationEntity::class.java) + val entity = box.query(AthleteCalibrationEntity_.offlineUserId.equal("local")) + .build().use { it.findFirst() } + return AthleteCalibration( + trainingAgeMonths = entity?.trainingAgeMonths ?: 0, + historicalTrainingDaysPerWeek = entity?.historicalTrainingDaysPerWeek?.takeIf { it in 1..7 } ?: 3, + importedHistory = entity?.importedHistory ?: false, + weeklyGoalDays = entity?.weeklyGoalDays ?: weeklyGoal, + bodyweightKg = entity?.bodyweightKg, + hasPastTraining = entity?.hasPastTraining ?: false, + hasGymAccess = entity?.hasGymAccess ?: true, + baselinePushups = entity?.baselinePushups ?: 0, + baselinePullups = entity?.baselinePullups ?: 0, + baselineBenchKg = entity?.baselineBenchKg ?: 0, + baselineLatPulldownKg = entity?.baselineLatPulldownKg ?: 0, + baselineMileRunSeconds = entity?.baselineMileRunSeconds ?: 0, + ) + } + + private fun buildRecommendation(history: List): Pair { + val readiness = RecoveryReadinessEngine.readinessByRegion(history) + + val planBox = boxStore.boxFor(PlanEntity::class.java) + val dayBox = boxStore.boxFor(PlanDayEntity::class.java) + val planExerciseBox = boxStore.boxFor(PlanExerciseEntity::class.java) + val exerciseBox = boxStore.boxFor(ExerciseEntity::class.java) + + val activePlan = planBox.query(PlanEntity_.isActive.equal(true)) + .build().use { it.findFirst() } + ?: return "No Plan" to "" + + val days = dayBox.query(PlanDayEntity_.planUid.equal(activePlan.uid)) + .order(PlanDayEntity_.orderIndex) + .build().use { it.find() } + if (days.isEmpty()) return activePlan.name to "" + + // Collect all exercise UIDs across all days in one pass, then batch-load names. + val allPlanExercises = planExerciseBox.query( + PlanExerciseEntity_.planDayUid.oneOf(days.map { it.uid }.toTypedArray()) + ).order(PlanExerciseEntity_.orderIndex).build().use { it.find() } + val allUids = allPlanExercises.map { it.exerciseUid }.distinct() + val exerciseNameByUid: Map = if (allUids.isEmpty()) emptyMap() + else exerciseBox.query(ExerciseEntity_.uid.oneOf(allUids.toTypedArray())) + .build().use { q -> q.find() } + .associate { it.uid to it.name } + val dayExerciseNames = days.map { day -> + allPlanExercises + .filter { it.planDayUid == day.uid } + .mapNotNull { exerciseNameByUid[it.exerciseUid] } + } + val idx = if (days.size > 1) suggestionEngine.suggestDayIndex(readiness, dayExerciseNames) else 0 + val bestDay = days.getOrNull(idx) ?: days.first() + val blurb = if (days.size > 1) suggestionEngine.recommendationBlurb(readiness, bestDay.name) else "" + return bestDay.name to blurb + } + + private fun countIsoWeekSessions(history: List): Int { + val iso = WeekFields.ISO + val now = LocalDate.now() + val year = now.get(iso.weekBasedYear()) + val week = now.get(iso.weekOfWeekBasedYear()) + val fmt = DateTimeFormatter.ISO_LOCAL_DATE + return history.count { entry -> + runCatching { + val date = LocalDate.parse(entry.date.take(10), fmt) + date.get(iso.weekBasedYear()) == year && + date.get(iso.weekOfWeekBasedYear()) == week + }.getOrDefault(false) + } + } + + private fun titleForGrade(grade: String): String = when (grade) { + "Uncalibrated" -> "Ledger Initiate" + "Graphite" -> "Graphite Trainee" + "Iron" -> "Iron Regular" + "Steel" -> "Steel Builder" + "Titanium" -> "Titanium Athlete" + "Obsidian" -> "Obsidian Veteran" + "Iridium" -> "Iridium Specialist" + "Aether" -> "Aether Standard" + "Apex" -> "Apex Ledger" + else -> "Ledger Initiate" + } + + private fun readLatestBadgeTitle(fallback: String): String { + val badgeId = boxStore.boxFor(GamificationProfileEntity::class.java) + .query() + .build() + .use { query -> + query.findFirst() + ?.unlockedBadges + ?.split(",") + ?.map(String::trim) + ?.lastOrNull(String::isNotBlank) + } + return badgeId + ?.replace('_', ' ') + ?.split(' ') + ?.joinToString(" ") { token -> token.replaceFirstChar(Char::titlecase) } + ?: fallback + } + + private fun readActiveWorkoutDayName(): String? = + boxStore.boxFor(AppSettingEntity::class.java) + .query(com.ironlog.app.data.objectbox.AppSettingEntity_.key.equal("active_workout_day_name")) + .build().use { it.findFirst()?.value?.takeIf(String::isNotBlank) } + + private fun readSettingString(key: String): String? = + boxStore.boxFor(AppSettingEntity::class.java) + .query(com.ironlog.app.data.objectbox.AppSettingEntity_.key.equal(key)) + .build().use { it.findFirst()?.value?.takeIf(String::isNotBlank) } + + private fun readDailyReminderMinutesOfDay(): Int? { + return readSettingString("dailyReminderTimeMinutes") + ?.toDoubleOrNull() + ?.toInt() + ?.takeIf { it in 0 until 24 * 60 } + } + + private fun routeForVisualState( + visualState: WidgetVisualState, + fallback: String, + ): String = when (visualState) { + WidgetVisualState.AT_RISK, + WidgetVisualState.WORKOUT_SOON, + WidgetVisualState.EARLY_WORKOUT, + WidgetVisualState.NO_EXCUSES -> "Home" + WidgetVisualState.RECOVERY_DAY -> "RecoveryMap" + WidgetVisualState.WORKOUT_DONE, + WidgetVisualState.NEW_PB, + WidgetVisualState.COMEBACK, + WidgetVisualState.MISSION_COMPLETE -> "statusWindow" + WidgetVisualState.ACTIVE_STREAK -> fallback + } +} diff --git a/app/src/main/java/com/ironlog/app/widget/WidgetState.kt b/app/src/main/java/com/ironlog/app/widget/WidgetState.kt new file mode 100644 index 0000000..d2fa7cb --- /dev/null +++ b/app/src/main/java/com/ironlog/app/widget/WidgetState.kt @@ -0,0 +1,68 @@ +package com.ironlog.app.widget + +import kotlinx.serialization.Serializable + +/** + * Canonical Glance widget snapshot sourced from Iron Ledger state. + */ +@Serializable +data class WidgetState( + // Ledger identity + val grade: String = "Uncalibrated", + val level: Int = 1, + val title: String = "Ledger Initiate", + val dailyStreakDays: Int = 0, + val streakWeeks: Int = 0, + val integrityScore: Double = 1.0, + + // XP progression + val totalXp: Long = 0L, + val xpInLevel: Long = 0L, + val xpForNextLevel: Long = 125L, + + // Training signals + val signalStrength: Int = 1, + val signalPower: Int = 1, + val signalHypertrophy: Int = 1, + val signalEndurance: Int = 1, + val signalAgility: Int = 1, + val signalDiscipline: Int = 1, + val signalRecovery: Int = 1, + + // Workout recommendation + val recommendedDayName: String = "No Plan", + val recommendedDayBlurb: String = "", + + // Weekly progress + val weekSessionsCount: Int = 0, + val weeklyGoal: Int = 4, + val weeklyCompletion: List = emptyList(), + + // Next checkpoint + val nextGradeLabel: String = "", + val nextGradeOutstanding: Int = 0, + + // Daily proof surface + val dailyProofHeadline: String = "Set your proof loop", + val dailyProofDetail: String = "", + val primaryActionLabel: String = "Choose a program", + val primaryActionRoute: String = "ProgramPicker", + val foxExpressionId: String = "forgefox_20_clipboard", + val latestBadgeTitle: String = "", + val todayCompleted: Boolean = false, + val minutesUntilWorkout: Int? = null, + val isAtRisk: Boolean = false, + val isRecoveryDay: Boolean = false, + val hasNewPb: Boolean = false, + val visualState: WidgetVisualState = WidgetVisualState.ACTIVE_STREAK, + + val lastUpdatedEpochMs: Long = 0L, +) { + val xpPercent: Float + get() = if (xpForNextLevel <= 0L) 0f + else (xpInLevel.toFloat() / xpForNextLevel.toFloat()).coerceIn(0f, 1f) + + val weekPercent: Float + get() = if (weeklyGoal <= 0) 0f + else (weekSessionsCount.toFloat() / weeklyGoal.toFloat()).coerceIn(0f, 1f) +} diff --git a/app/src/main/java/com/ironlog/app/widget/WidgetStateDefinition.kt b/app/src/main/java/com/ironlog/app/widget/WidgetStateDefinition.kt new file mode 100644 index 0000000..be91abc --- /dev/null +++ b/app/src/main/java/com/ironlog/app/widget/WidgetStateDefinition.kt @@ -0,0 +1,41 @@ +package com.ironlog.app.widget + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.Serializer +import androidx.glance.state.GlanceStateDefinition +import kotlinx.serialization.json.Json +import java.io.File +import java.io.InputStream +import java.io.OutputStream + +/** + * Glance state definition that persists [WidgetState] as JSON in a per-widget DataStore file. + */ +object WidgetStateDefinition : GlanceStateDefinition { + + private const val DATA_STORE_FILENAME = "ironlog_widget_state" + + override suspend fun getDataStore(context: Context, fileKey: String): DataStore = + DataStoreFactory.create( + serializer = WidgetStateSerializer, + produceFile = { File(context.dataDir, "datastore/$DATA_STORE_FILENAME-$fileKey.json") }, + ) + + override fun getLocation(context: Context, fileKey: String): File = + File(context.dataDir, "datastore/$DATA_STORE_FILENAME-$fileKey.json") +} + +private object WidgetStateSerializer : Serializer { + override val defaultValue: WidgetState = WidgetState() + + override suspend fun readFrom(input: InputStream): WidgetState = + runCatching { + Json.decodeFromString(input.readBytes().decodeToString()) + }.getOrDefault(defaultValue) + + override suspend fun writeTo(t: WidgetState, output: OutputStream) { + output.write(Json.encodeToString(WidgetState.serializer(), t).toByteArray()) + } +} diff --git a/app/src/main/java/com/ironlog/app/widget/WidgetUpdateWorker.kt b/app/src/main/java/com/ironlog/app/widget/WidgetUpdateWorker.kt new file mode 100644 index 0000000..b2296a9 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/widget/WidgetUpdateWorker.kt @@ -0,0 +1,188 @@ +package com.ironlog.app.widget + +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.appwidget.updateAll +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.ironlog.app.data.objectbox.AppSettingEntity_ +import com.ironlog.app.data.objectbox.ObjectBox +import com.ironlog.app.data.objectbox.WorkoutEntity +import com.ironlog.app.data.objectbox.WorkoutEntity_ +import com.ironlog.app.data.objectbox.ExerciseEntity +import com.ironlog.app.data.objectbox.ExerciseEntity_ +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity +import com.ironlog.app.data.objectbox.WorkoutExerciseEntity_ +import com.ironlog.app.data.objectbox.WorkoutSetEntity +import com.ironlog.app.data.objectbox.WorkoutSetEntity_ +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.HistoryExercise +import com.ironlog.app.ui.model.HistoryExerciseSet +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit + +/** + * WorkManager worker that refreshes all IronLog widgets every 30 minutes. + * + * Also call [enqueueOneTime] after any workout is saved to push a debounced near-immediate update. + */ +class WidgetUpdateWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + runCatching { + val box = ObjectBox.store + + // Load history from ObjectBox + val workoutBox = box.boxFor(WorkoutEntity::class.java) + val exerciseBox = box.boxFor(WorkoutExerciseEntity::class.java) + val libraryExerciseBox = box.boxFor(ExerciseEntity::class.java) + val setBox = box.boxFor(WorkoutSetEntity::class.java) + + val workouts = workoutBox.query(WorkoutEntity_.status.equal("completed")) + .orderDesc(WorkoutEntity_.startedAt) + .build().use { it.find() } + + // Batch-load all workout exercises and sets in two queries, then map by parent UID. + val workoutUids = workouts.map { it.uid }.toTypedArray() + val allExercises: List = if (workoutUids.isEmpty()) emptyList() + else exerciseBox.query(WorkoutExerciseEntity_.workoutUid.oneOf(workoutUids)) + .order(WorkoutExerciseEntity_.orderIndex).build().use { it.find() } + val exerciseUids = allExercises.map { it.uid }.toTypedArray() + val allSets: List = if (exerciseUids.isEmpty()) emptyList() + else setBox.query(WorkoutSetEntity_.workoutExerciseUid.oneOf(exerciseUids)) + .order(WorkoutSetEntity_.setIndex).build().use { it.find() } + // Batch-load all exercise library names in one query. + val libraryUids = allExercises.map { it.exerciseUid }.distinct().toTypedArray() + val libraryNameByUid: Map = if (libraryUids.isEmpty()) emptyMap() + else libraryExerciseBox.query(ExerciseEntity_.uid.oneOf(libraryUids)) + .build().use { q -> q.find() }.associate { it.uid to it.name } + val exercisesByWorkout = allExercises.groupBy { it.workoutUid } + val setsByExercise = allSets.groupBy { it.workoutExerciseUid } + + val history: List = workouts.map { w -> + val historyExercises = (exercisesByWorkout[w.uid] ?: emptyList()).map { we -> + val sets = setsByExercise[we.uid] ?: emptyList() + HistoryExercise( + id = we.uid, + exerciseId = we.exerciseUid, + name = libraryNameByUid[we.exerciseUid] ?: "Exercise", + sets = sets.map { s -> + HistoryExerciseSet( + id = s.uid, + weight = s.weight, + reps = s.reps, + type = when { + s.isWarmup -> "warmup" + s.isDropset -> "dropset" + else -> "normal" + }, + rpe = s.rpe, + rir = s.rir, + restSeconds = s.restSeconds, + ) + }, + ) + } + HistoryEntry( + id = w.uid, + date = java.time.Instant.ofEpochMilli(w.startedAt) + .atZone(java.time.ZoneId.systemDefault()) + .toLocalDate().toString(), + duration = w.durationSeconds, + rating = w.rating, + name = w.name, + imported = w.imported, + exercises = historyExercises, + ) + } + + // Load weekly goal from settings + val settingBox = box.boxFor(com.ironlog.app.data.objectbox.AppSettingEntity::class.java) + val weeklyGoal = settingBox.query(AppSettingEntity_.key.equal("weeklyGoalDays")) + .build().use { it.findFirst()?.value?.toIntOrNull() } ?: 4 + + // Build shared WidgetState + val repo = WidgetDataRepository(applicationContext, box) + val state = repo.buildWidgetState(history, weeklyGoal) + + // Write state to all BadgeWidget instances + val badgeIds = GlanceAppWidgetManager(applicationContext) + .getGlanceIds(BadgeWidget::class.java) + badgeIds.forEach { id -> + updateAppWidgetState(applicationContext, WidgetStateDefinition, id) { state } + } + + // Write state to all DashboardWidget instances + val dashIds = GlanceAppWidgetManager(applicationContext) + .getGlanceIds(DashboardWidget::class.java) + dashIds.forEach { id -> + updateAppWidgetState(applicationContext, WidgetStateDefinition, id) { state } + } + + // Write state to all WarRoomWidget instances + val warIds = GlanceAppWidgetManager(applicationContext) + .getGlanceIds(WarRoomWidget::class.java) + warIds.forEach { id -> + updateAppWidgetState(applicationContext, WidgetStateDefinition, id) { state } + } + + // Trigger UI recomposition for all widget instances + BadgeWidget().updateAll(applicationContext) + DashboardWidget().updateAll(applicationContext) + WarRoomWidget().updateAll(applicationContext) + }.fold( + onSuccess = { Result.success() }, + onFailure = { Result.retry() }, + ) + } + + companion object { + private const val PERIODIC_WORK_NAME = "ironlog_widget_refresh" + private const val ONETIME_WORK_NAME = "ironlog_widget_refresh_now" + + /** + * Enqueue the periodic 30-minute refresh. Call once from Application.onCreate(). + */ + fun enqueuePeriodic(context: Context) { + val request = PeriodicWorkRequestBuilder(30, TimeUnit.MINUTES) + .setConstraints( + Constraints.Builder() + .setRequiresBatteryNotLow(true) + .build() + ) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + + /** + * Enqueue a debounced one-time refresh (e.g. after workout saved). + * Uses REPLACE policy with a 2-second initial delay to natively debounce rapid-fire triggers + * (e.g. rapid database writes during active tracking). + */ + fun enqueueOneTime(context: Context) { + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(2, TimeUnit.SECONDS) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + ONETIME_WORK_NAME, + ExistingWorkPolicy.REPLACE, + request, + ) + } + } +} diff --git a/app/src/main/java/com/ironlog/app/widget/WidgetVisualStateResolver.kt b/app/src/main/java/com/ironlog/app/widget/WidgetVisualStateResolver.kt new file mode 100644 index 0000000..a1bd230 --- /dev/null +++ b/app/src/main/java/com/ironlog/app/widget/WidgetVisualStateResolver.kt @@ -0,0 +1,81 @@ +package com.ironlog.app.widget + +import kotlinx.serialization.Serializable +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +@Serializable +enum class WidgetVisualState { + ACTIVE_STREAK, + AT_RISK, + WORKOUT_SOON, + WORKOUT_DONE, + RECOVERY_DAY, + EARLY_WORKOUT, + NO_EXCUSES, + NEW_PB, + COMEBACK, + MISSION_COMPLETE, +} + +data class WidgetVisualInputs( + val streakDays: Int, + val todayCompleted: Boolean, + val weekSessionsCount: Int, + val weeklyGoal: Int, + val minutesUntilWorkout: Int?, + val scheduledWorkoutHour: Int?, + val isAtRisk: Boolean, + val isRecoveryDay: Boolean, + val hasRecentNewPb: Boolean, + val returnedAfterGap: Boolean, +) + +fun buildWeeklyCompletion( + workoutDates: Set, + weekStart: LocalDate, +): List = + (0..6).map { offset -> weekStart.plusDays(offset.toLong()) in workoutDates } + +fun minutesUntilScheduledTime(now: LocalDateTime, scheduledMinutesOfDay: Int): Int { + val safeMinutes = scheduledMinutesOfDay.coerceIn(0, 23 * 60 + 59) + var next = now.toLocalDate().atStartOfDay().plusMinutes(safeMinutes.toLong()) + if (next.isBefore(now)) next = next.plusDays(1) + return ChronoUnit.MINUTES.between(now, next).toInt().coerceAtLeast(0) +} + +fun isRecentWidgetEvent( + eventEpochMs: Long?, + nowEpochMs: Long, + maxAgeHours: Long, +): Boolean { + val event = eventEpochMs ?: return false + if (event <= 0L || event > nowEpochMs) return false + return nowEpochMs - event <= maxAgeHours * 60L * 60L * 1000L +} + +fun resolveWidgetVisualState(inputs: WidgetVisualInputs): WidgetVisualState { + if (inputs.hasRecentNewPb) return WidgetVisualState.NEW_PB + if (inputs.todayCompleted && inputs.weekSessionsCount >= inputs.weeklyGoal.coerceAtLeast(1)) { + return WidgetVisualState.MISSION_COMPLETE + } + if (inputs.todayCompleted && inputs.returnedAfterGap) return WidgetVisualState.COMEBACK + if (inputs.todayCompleted) return WidgetVisualState.WORKOUT_DONE + if (inputs.isRecoveryDay) return WidgetVisualState.RECOVERY_DAY + + val soon = inputs.minutesUntilWorkout?.let { it in 0..90 } == true + if (soon) { + return if ((inputs.scheduledWorkoutHour ?: 24) < 9) { + WidgetVisualState.EARLY_WORKOUT + } else { + WidgetVisualState.WORKOUT_SOON + } + } + + if (inputs.isAtRisk) return WidgetVisualState.AT_RISK + if (inputs.streakDays > 0 && (inputs.scheduledWorkoutHour ?: 24) < 12) { + return WidgetVisualState.NO_EXCUSES + } + return WidgetVisualState.ACTIVE_STREAK +} diff --git a/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/app/src/main/res/drawable-hdpi/splashscreen_logo.png new file mode 100644 index 0000000..b3cc6f3 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ diff --git a/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/app/src/main/res/drawable-mdpi/splashscreen_logo.png new file mode 100644 index 0000000..de98138 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_01_neutral.png b/app/src/main/res/drawable-nodpi/forgefox_01_neutral.png new file mode 100644 index 0000000..3175e94 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_01_neutral.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_02_smile.png b/app/src/main/res/drawable-nodpi/forgefox_02_smile.png new file mode 100644 index 0000000..c816c7e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_02_smile.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_03_big_happy.png b/app/src/main/res/drawable-nodpi/forgefox_03_big_happy.png new file mode 100644 index 0000000..c24242e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_03_big_happy.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_04_excited.png b/app/src/main/res/drawable-nodpi/forgefox_04_excited.png new file mode 100644 index 0000000..bc495aa Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_04_excited.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_05_laughing.png b/app/src/main/res/drawable-nodpi/forgefox_05_laughing.png new file mode 100644 index 0000000..688004e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_05_laughing.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_06_wink_teasing.png b/app/src/main/res/drawable-nodpi/forgefox_06_wink_teasing.png new file mode 100644 index 0000000..199de6d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_06_wink_teasing.png differ diff --git a/assets/site/forgefox_determined.png b/app/src/main/res/drawable-nodpi/forgefox_07_determined.png similarity index 100% rename from assets/site/forgefox_determined.png rename to app/src/main/res/drawable-nodpi/forgefox_07_determined.png diff --git a/app/src/main/res/drawable-nodpi/forgefox_08_serious.png b/app/src/main/res/drawable-nodpi/forgefox_08_serious.png new file mode 100644 index 0000000..0435e05 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_08_serious.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_09_angry_coach.png b/app/src/main/res/drawable-nodpi/forgefox_09_angry_coach.png new file mode 100644 index 0000000..e4d3be5 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_09_angry_coach.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_10_disappointed.png b/app/src/main/res/drawable-nodpi/forgefox_10_disappointed.png new file mode 100644 index 0000000..29bc9dd Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_10_disappointed.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_11_sad.png b/app/src/main/res/drawable-nodpi/forgefox_11_sad.png new file mode 100644 index 0000000..29bc9dd Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_11_sad.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_12_exhausted.png b/app/src/main/res/drawable-nodpi/forgefox_12_exhausted.png new file mode 100644 index 0000000..ad430b7 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_12_exhausted.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_13_sleepy.png b/app/src/main/res/drawable-nodpi/forgefox_13_sleepy.png new file mode 100644 index 0000000..b9eeb73 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_13_sleepy.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_14_shocked.png b/app/src/main/res/drawable-nodpi/forgefox_14_shocked.png new file mode 100644 index 0000000..4f533e5 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_14_shocked.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_15_confused.png b/app/src/main/res/drawable-nodpi/forgefox_15_confused.png new file mode 100644 index 0000000..cd86be1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_15_confused.png differ diff --git a/assets/site/forgefox_proud.png b/app/src/main/res/drawable-nodpi/forgefox_16_proud.png similarity index 100% rename from assets/site/forgefox_proud.png rename to app/src/main/res/drawable-nodpi/forgefox_16_proud.png diff --git a/app/src/main/res/drawable-nodpi/forgefox_17_flexing.png b/app/src/main/res/drawable-nodpi/forgefox_17_flexing.png new file mode 100644 index 0000000..fdec993 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_17_flexing.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_18_fist_pump.png b/app/src/main/res/drawable-nodpi/forgefox_18_fist_pump.png new file mode 100644 index 0000000..b0fa3d3 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_18_fist_pump.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_19_pointing_forward.png b/app/src/main/res/drawable-nodpi/forgefox_19_pointing_forward.png new file mode 100644 index 0000000..1e5011e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_19_pointing_forward.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_20_clipboard.png b/app/src/main/res/drawable-nodpi/forgefox_20_clipboard.png new file mode 100644 index 0000000..6ea0633 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_20_clipboard.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_21_water_bottle.png b/app/src/main/res/drawable-nodpi/forgefox_21_water_bottle.png new file mode 100644 index 0000000..5c9b3b5 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_21_water_bottle.png differ diff --git a/assets/site/forgefox_dumbbell.png b/app/src/main/res/drawable-nodpi/forgefox_22_dumbbell.png similarity index 100% rename from assets/site/forgefox_dumbbell.png rename to app/src/main/res/drawable-nodpi/forgefox_22_dumbbell.png diff --git a/app/src/main/res/drawable-nodpi/forgefox_23_pull_up.png b/app/src/main/res/drawable-nodpi/forgefox_23_pull_up.png new file mode 100644 index 0000000..5c142de Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_23_pull_up.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_24_tired_towel.png b/app/src/main/res/drawable-nodpi/forgefox_24_tired_towel.png new file mode 100644 index 0000000..7529e92 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_24_tired_towel.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_25_trophy_medal.png b/app/src/main/res/drawable-nodpi/forgefox_25_trophy_medal.png new file mode 100644 index 0000000..8ef353e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_25_trophy_medal.png differ diff --git a/assets/site/forgefox_streak.png b/app/src/main/res/drawable-nodpi/forgefox_26_streak_fire.png similarity index 100% rename from assets/site/forgefox_streak.png rename to app/src/main/res/drawable-nodpi/forgefox_26_streak_fire.png diff --git a/app/src/main/res/drawable-nodpi/forgefox_27_repair_streak.png b/app/src/main/res/drawable-nodpi/forgefox_27_repair_streak.png new file mode 100644 index 0000000..086abcd Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_27_repair_streak.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_28_rest_blanket.png b/app/src/main/res/drawable-nodpi/forgefox_28_rest_blanket.png new file mode 100644 index 0000000..cb693b5 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_28_rest_blanket.png differ diff --git a/assets/site/forgefox_watch.png b/app/src/main/res/drawable-nodpi/forgefox_29_checking_watch.png similarity index 100% rename from assets/site/forgefox_watch.png rename to app/src/main/res/drawable-nodpi/forgefox_29_checking_watch.png diff --git a/app/src/main/res/drawable-nodpi/forgefox_30_coach_arms_crossed.png b/app/src/main/res/drawable-nodpi/forgefox_30_coach_arms_crossed.png new file mode 100644 index 0000000..48efb11 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_30_coach_arms_crossed.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_31_supportive_failure.png b/app/src/main/res/drawable-nodpi/forgefox_31_supportive_failure.png new file mode 100644 index 0000000..d7ffe32 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_31_supportive_failure.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_32_level_up.png b/app/src/main/res/drawable-nodpi/forgefox_32_level_up.png new file mode 100644 index 0000000..611818d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_32_level_up.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_widget_active.png b/app/src/main/res/drawable-nodpi/forgefox_widget_active.png new file mode 100644 index 0000000..37cd16c Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_widget_active.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_widget_at_risk.png b/app/src/main/res/drawable-nodpi/forgefox_widget_at_risk.png new file mode 100644 index 0000000..bd9e31c Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_widget_at_risk.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_widget_coach.png b/app/src/main/res/drawable-nodpi/forgefox_widget_coach.png new file mode 100644 index 0000000..380582e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_widget_coach.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_widget_comeback.png b/app/src/main/res/drawable-nodpi/forgefox_widget_comeback.png new file mode 100644 index 0000000..b77b503 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_widget_comeback.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_widget_done.png b/app/src/main/res/drawable-nodpi/forgefox_widget_done.png new file mode 100644 index 0000000..bd561c8 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_widget_done.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_widget_early_workout.png b/app/src/main/res/drawable-nodpi/forgefox_widget_early_workout.png new file mode 100644 index 0000000..7b5f957 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_widget_early_workout.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_widget_mission_complete.png b/app/src/main/res/drawable-nodpi/forgefox_widget_mission_complete.png new file mode 100644 index 0000000..32ccaa9 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_widget_mission_complete.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_widget_pb.png b/app/src/main/res/drawable-nodpi/forgefox_widget_pb.png new file mode 100644 index 0000000..a5109e7 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_widget_pb.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_widget_recovery.png b/app/src/main/res/drawable-nodpi/forgefox_widget_recovery.png new file mode 100644 index 0000000..4a4d7e9 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_widget_recovery.png differ diff --git a/app/src/main/res/drawable-nodpi/forgefox_widget_workout_soon.png b/app/src/main/res/drawable-nodpi/forgefox_widget_workout_soon.png new file mode 100644 index 0000000..3afa945 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/forgefox_widget_workout_soon.png differ diff --git a/app/src/main/res/drawable-nodpi/ic_forge_streak_dumbbell.png b/app/src/main/res/drawable-nodpi/ic_forge_streak_dumbbell.png new file mode 100644 index 0000000..c674b5e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_forge_streak_dumbbell.png differ diff --git a/app/src/main/res/drawable-nodpi/iron_grade_aether.png b/app/src/main/res/drawable-nodpi/iron_grade_aether.png new file mode 100644 index 0000000..68eef70 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/iron_grade_aether.png differ diff --git a/app/src/main/res/drawable-nodpi/iron_grade_apex.png b/app/src/main/res/drawable-nodpi/iron_grade_apex.png new file mode 100644 index 0000000..225761e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/iron_grade_apex.png differ diff --git a/app/src/main/res/drawable-nodpi/iron_grade_graphite.png b/app/src/main/res/drawable-nodpi/iron_grade_graphite.png new file mode 100644 index 0000000..87809b7 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/iron_grade_graphite.png differ diff --git a/app/src/main/res/drawable-nodpi/iron_grade_iridium.png b/app/src/main/res/drawable-nodpi/iron_grade_iridium.png new file mode 100644 index 0000000..bb4d61d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/iron_grade_iridium.png differ diff --git a/assets/site/iron_grade_iron.png b/app/src/main/res/drawable-nodpi/iron_grade_iron.png similarity index 100% rename from assets/site/iron_grade_iron.png rename to app/src/main/res/drawable-nodpi/iron_grade_iron.png diff --git a/app/src/main/res/drawable-nodpi/iron_grade_obsidian.png b/app/src/main/res/drawable-nodpi/iron_grade_obsidian.png new file mode 100644 index 0000000..36ffee6 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/iron_grade_obsidian.png differ diff --git a/app/src/main/res/drawable-nodpi/iron_grade_steel.png b/app/src/main/res/drawable-nodpi/iron_grade_steel.png new file mode 100644 index 0000000..9085536 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/iron_grade_steel.png differ diff --git a/assets/site/iron_grade_titanium.png b/app/src/main/res/drawable-nodpi/iron_grade_titanium.png similarity index 100% rename from assets/site/iron_grade_titanium.png rename to app/src/main/res/drawable-nodpi/iron_grade_titanium.png diff --git a/app/src/main/res/drawable-nodpi/iron_grade_uncalibrated.png b/app/src/main/res/drawable-nodpi/iron_grade_uncalibrated.png new file mode 100644 index 0000000..babbc70 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/iron_grade_uncalibrated.png differ diff --git a/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/app/src/main/res/drawable-xhdpi/splashscreen_logo.png new file mode 100644 index 0000000..99d21b7 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ diff --git a/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..cd2be1e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ diff --git a/assets/site/splashscreen_logo.png b/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png similarity index 100% rename from assets/site/splashscreen_logo.png rename to app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..f0baa00 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_stat_ironlog.xml b/app/src/main/res/drawable/ic_stat_ironlog.xml new file mode 100644 index 0000000..80511e6 --- /dev/null +++ b/app/src/main/res/drawable/ic_stat_ironlog.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/logo_iron.png b/app/src/main/res/drawable/logo_iron.png new file mode 100644 index 0000000..4bc2f91 Binary files /dev/null and b/app/src/main/res/drawable/logo_iron.png differ diff --git a/app/src/main/res/drawable/logo_log.png b/app/src/main/res/drawable/logo_log.png new file mode 100644 index 0000000..2aa8e86 Binary files /dev/null and b/app/src/main/res/drawable/logo_log.png differ diff --git a/app/src/main/res/drawable/splashscreen_logo_mono.xml b/app/src/main/res/drawable/splashscreen_logo_mono.xml new file mode 100644 index 0000000..f0baa00 --- /dev/null +++ b/app/src/main/res/drawable/splashscreen_logo_mono.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/fonts/lexend_variable.ttf b/app/src/main/res/font/lexend_variable.ttf similarity index 100% rename from assets/fonts/lexend_variable.ttf rename to app/src/main/res/font/lexend_variable.ttf diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..20ba651 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..20ba651 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..32232d8 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..030ddcc Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..32232d8 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..d1c177d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..ee5b78a Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..d1c177d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..199475d Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..0114c84 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..199475d Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..95a7c0c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..fe23f3a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..95a7c0c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aefec56 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..01b89e7 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..aefec56 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..8965e93 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + + #080808 + #080808 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..e25aba5 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + IronLog + IronLog grade and streak badge + IronLog daily dashboard with ledger progress and workout + IronLog training dashboard with stats and checkpoints + diff --git a/app/src/main/res/values/strings_onboarding.xml b/app/src/main/res/values/strings_onboarding.xml new file mode 100644 index 0000000..9e51438 --- /dev/null +++ b/app/src/main/res/values/strings_onboarding.xml @@ -0,0 +1,49 @@ + + + Preparing your training workspace + Calibrating local coaching signals... + Athlete profile + Tell Ironlog what to call you. + Your name + Continue + Choose your starting point + This only tunes defaults. Real progress comes from verified workouts. + Set your weekly rhythm + %d sessions per week + Save rhythm + Choose your training focus + Continue + Optional AI coaching + Use local intelligence by default, or connect your own cloud API key. + Local coaching is active + Recovery scoring, workout suggestions, and effort tracking stay on device. + Set up cloud AI + Cloud AI ready + Paste your API key here + Stored locally in encrypted storage, never uploaded. + Gemini 2.0 Flash is recommended for the free tier: large context and fast plan generation. + Get a Gemini key at aistudio.google.com + Continue + Skip cloud AI + Enable app features + Each permission improves the app. All are optional. + Camera for QR sharing + Scan and share workout plans as QR codes. + Health Connect recovery data + Import sleep, HRV, and heart rate for better recovery scores. + Workout notifications + Rest timer, active workout, streak, and reminder alerts. + Allow + Allowed + Not allowed + You can enable this later in Settings. + Continue + Setup complete + Your training record starts with clean data and strict progression. + You can change these defaults anytime in Settings. + Start training + Add Ironlog to your home screen + Quick-start workouts and check your streak without opening the app. + Add widget + Maybe later + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..f5534d2 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..24bad93 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/xml/badge_widget_info.xml b/app/src/main/res/xml/badge_widget_info.xml new file mode 100644 index 0000000..6dbba8f --- /dev/null +++ b/app/src/main/res/xml/badge_widget_info.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/xml/dashboard_widget_info.xml b/app/src/main/res/xml/dashboard_widget_info.xml new file mode 100644 index 0000000..448c23d --- /dev/null +++ b/app/src/main/res/xml/dashboard_widget_info.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..3c332df --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..7c30af9 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/xml/warroom_widget_info.xml b/app/src/main/res/xml/warroom_widget_info.xml new file mode 100644 index 0000000..e836bf5 --- /dev/null +++ b/app/src/main/res/xml/warroom_widget_info.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/test/java/com/ironlog/app/data/exercise/ExerciseTrackingTypeNormalizerTest.kt b/app/src/test/java/com/ironlog/app/data/exercise/ExerciseTrackingTypeNormalizerTest.kt new file mode 100644 index 0000000..b7a36b2 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/data/exercise/ExerciseTrackingTypeNormalizerTest.kt @@ -0,0 +1,96 @@ +package com.ironlog.app.data.exercise + +import com.ironlog.app.util.ExerciseTrackingTypeNormalizer +import org.junit.Assert.assertEquals +import org.junit.Test + +class ExerciseTrackingTypeNormalizerTest { + @Test + fun `rep based strength exercises override bad duration seed data`() { + assertEquals( + "weight_reps", + ExerciseTrackingTypeNormalizer.normalize( + name = "Ab Crunch Machine", + category = "strength", + equipment = "Machine", + explicitTrackingType = "duration_distance", + ), + ) + assertEquals( + "weight_reps", + ExerciseTrackingTypeNormalizer.normalize( + name = "Cable Crunch", + category = "strength", + equipment = "Cable", + explicitTrackingType = "duration_distance", + ), + ) + assertEquals( + "weight_reps", + ExerciseTrackingTypeNormalizer.normalize( + name = "Seated Cable Row", + category = "strength", + equipment = "Cable", + explicitTrackingType = "duration_distance", + ), + ) + assertEquals( + "weight_reps", + ExerciseTrackingTypeNormalizer.normalize( + name = "Dumbbell Seated Box Jump", + category = "strength", + equipment = "Dumbbell", + explicitTrackingType = "duration", + ), + ) + } + + @Test + fun `time and distance exercises stay time based`() { + assertEquals( + "duration", + ExerciseTrackingTypeNormalizer.normalize( + name = "Front Plank", + category = "mobility", + equipment = "Bodyweight", + explicitTrackingType = null, + ), + ) + assertEquals( + "duration", + ExerciseTrackingTypeNormalizer.normalize( + name = "Hip 90-90 Stretch", + category = "mobility", + equipment = "Bodyweight", + explicitTrackingType = "duration", + ), + ) + assertEquals( + "duration", + ExerciseTrackingTypeNormalizer.normalize( + name = "Standing Hamstring and Calf Stretch", + category = "mobility", + equipment = "Bodyweight", + explicitTrackingType = "duration", + ), + ) + assertEquals( + "duration_distance", + ExerciseTrackingTypeNormalizer.normalize( + name = "Treadmill Walk", + category = "cardio", + equipment = "Machine", + explicitTrackingType = null, + ), + ) + assertEquals( + "duration_distance", + ExerciseTrackingTypeNormalizer.normalize( + name = "Ski Erg", + category = "cardio", + equipment = "Conditioning", + explicitTrackingType = "duration_distance", + ), + ) + } +} diff --git a/app/src/test/java/com/ironlog/app/data/health/HealthConnectRepositoryTest.kt b/app/src/test/java/com/ironlog/app/data/health/HealthConnectRepositoryTest.kt new file mode 100644 index 0000000..7525388 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/data/health/HealthConnectRepositoryTest.kt @@ -0,0 +1,74 @@ +// app/src/test/java/com/ironlog/app/data/health/HealthConnectRepositoryTest.kt +package com.ironlog.app.data.health + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class HealthConnectRepositoryTest { + + // ── Biometric readiness score ───────────────────────────────────────── + + @Test fun `sleepScore returns 1_0 for 8+ hours`() { + assertEquals(1.0, sleepScore(8.0), 0.001) + assertEquals(1.0, sleepScore(9.0), 0.001) + } + + @Test fun `sleepScore returns 0_0 for 4 hours or less`() { + assertEquals(0.0, sleepScore(4.0), 0.001) + assertEquals(0.0, sleepScore(3.0), 0.001) + } + + @Test fun `sleepScore scales linearly between 4 and 8 hours`() { + val score6h = sleepScore(6.0) + assertTrue("6h sleep score should be ~0.5 but was $score6h", score6h in 0.45..0.55) + } + + @Test fun `hrvScore returns 1_0 for HRV at or above 80 ms`() { + assertEquals(1.0, hrvScore(80.0), 0.001) + assertEquals(1.0, hrvScore(100.0), 0.001) + } + + @Test fun `hrvScore returns 0_0 for HRV at or below 20 ms`() { + assertEquals(0.0, hrvScore(20.0), 0.001) + assertEquals(0.0, hrvScore(10.0), 0.001) + } + + @Test fun `biometricReadinessScore blends sleep and HRV`() { + val snap = BiometricSnapshot(sleepHours = 8.0, hrvRmssd = 80.0) + val score = biometricReadinessScore(snap) + assertTrue("Score with full sleep+HRV should be >= 0.9", score >= 0.9) + } + + @Test fun `biometricReadinessScore with null data returns 0_5 neutral`() { + val snap = BiometricSnapshot() // all nulls + val score = biometricReadinessScore(snap) + assertEquals(0.5, score, 0.001) + } + + @Test fun `biometricReadinessScore with only sleep data uses sleep score`() { + val snap = BiometricSnapshot(sleepHours = 4.0) + val score = biometricReadinessScore(snap) + assertTrue("Poor sleep only should score < 0.3", score < 0.3) + } +} + +// ── Pure helper functions (extracted for testability) ─────────────────────── + +fun sleepScore(hours: Double): Double = ((hours - 4.0) / 4.0).coerceIn(0.0, 1.0) + +fun hrvScore(rmssd: Double): Double = ((rmssd - 20.0) / 60.0).coerceIn(0.0, 1.0) + +fun biometricReadinessScore(snap: BiometricSnapshot): Double { + val scores = buildList { + snap.sleepHours?.let { add(sleepScore(it) * 0.6) } + snap.hrvRmssd?.let { add(hrvScore(it) * 0.4) } + } + if (scores.isEmpty()) return 0.5 + // Normalize: sum of weights of present components + val weights = buildList { + if (snap.sleepHours != null) add(0.6) + if (snap.hrvRmssd != null) add(0.4) + } + return (scores.sum() / weights.sum()).coerceIn(0.0, 1.0) +} diff --git a/app/src/test/java/com/ironlog/app/data/repository/ImportExportRepositoryBackupSchemaTest.kt b/app/src/test/java/com/ironlog/app/data/repository/ImportExportRepositoryBackupSchemaTest.kt new file mode 100644 index 0000000..50158ea --- /dev/null +++ b/app/src/test/java/com/ironlog/app/data/repository/ImportExportRepositoryBackupSchemaTest.kt @@ -0,0 +1,14 @@ +package com.ironlog.app.data.repository + +import org.junit.Assert.assertTrue +import org.junit.Test + +class ImportExportRepositoryBackupSchemaTest { + + @Test + fun `full backup schema includes Iron Ledger and athlete state sections`() { + assertTrue(FULL_BACKUP_DATA_SECTIONS.contains("athlete_calibrations")) + assertTrue(FULL_BACKUP_DATA_SECTIONS.contains("gamification_profiles")) + assertTrue(FULL_BACKUP_DATA_SECTIONS.contains("iron_ledger_events")) + } +} diff --git a/app/src/test/java/com/ironlog/app/data/repository/ImportExportRepositoryCalibrationBackfillTest.kt b/app/src/test/java/com/ironlog/app/data/repository/ImportExportRepositoryCalibrationBackfillTest.kt new file mode 100644 index 0000000..edc4f17 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/data/repository/ImportExportRepositoryCalibrationBackfillTest.kt @@ -0,0 +1,57 @@ +package com.ironlog.app.data.repository + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.Instant +import java.time.ZoneOffset + +class ImportExportRepositoryCalibrationBackfillTest { + + @Test + fun `legacy restore backfills missing athlete calibration and gamification profile rows from settings`() { + val calibration = deriveFallbackAthleteCalibration( + settings = linkedMapOf( + "baseline_training_age_months" to ("19" to "string"), + "baseline_historical_training_days_per_week" to ("4" to "string"), + "baseline_bodyweight_kg" to ("70" to "string"), + "baseline_pushups" to ("40" to "string"), + "baseline_pullups" to ("14" to "string"), + "baseline_bench_kg" to ("65" to "string"), + "baseline_lat_pulldown_kg" to ("110" to "string"), + "baseline_mile_run_seconds" to ("570" to "string"), + "baseline_has_past_training" to ("true" to "boolean"), + "baseline_has_gym_access" to ("true" to "boolean"), + "ledger_imported_history" to ("true" to "boolean"), + "goalMode" to ("strength" to "string"), + "weightUnit" to ("kg" to "string"), + "weeklyGoalDays" to ("5" to "string"), + ), + hasImportedWorkouts = true, + nowMs = 1L, + ) + val profile = fallbackGamificationProfileRow() + + assertEquals(19, calibration.trainingAgeMonths) + assertEquals(4, calibration.historicalTrainingDaysPerWeek) + assertEquals(true, calibration.importedHistory) + assertEquals("local", calibration.offlineUserId) + assertEquals("local", profile.offlineUserId) + assertEquals(0L, profile.totalXp) + assertEquals("{}", profile.statsJson) + } + + @Test + fun `legacy date-only strings preserve their original calendar date`() { + val parsed = parseImportEpochMillis("2026-05-27", fallback = 99L, zoneId = ZoneOffset.UTC) + + assertEquals(Instant.parse("2026-05-27T00:00:00Z").toEpochMilli(), parsed) + } + + @Test + fun `legacy ISO instants and numeric timestamps are both preserved`() { + val instant = Instant.parse("2026-05-27T18:45:00Z").toEpochMilli() + + assertEquals(instant, parseImportEpochMillis("2026-05-27T18:45:00Z", 99L, ZoneOffset.UTC)) + assertEquals(instant, parseImportEpochMillis(instant, 99L, ZoneOffset.UTC)) + } +} diff --git a/app/src/test/java/com/ironlog/app/domain/ai/ExerciseResolutionEngineTest.kt b/app/src/test/java/com/ironlog/app/domain/ai/ExerciseResolutionEngineTest.kt new file mode 100644 index 0000000..d4744bb --- /dev/null +++ b/app/src/test/java/com/ironlog/app/domain/ai/ExerciseResolutionEngineTest.kt @@ -0,0 +1,57 @@ +package com.ironlog.app.domain.ai + +import com.ironlog.app.data.model.LegacyExerciseShape +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ExerciseResolutionEngineTest { + @Test + fun exactNameIsMatchedAtHighConfidence() { + val lib = listOf(ex("1", "Ab Crunch Machine"), ex("2", "Cable Lateral Raise")) + val result = ExerciseResolutionEngine.resolve("Ab Crunch Machine", lib) + assertEquals(ResolutionStatus.MATCHED, result.status) + assertNotNull(result.matched) + assertTrue(result.confidence >= 95) + } + + @Test + fun typoNameResolvesToBestMatch() { + val lib = listOf(ex("1", "Ab Crunch Machine"), ex("2", "Cable Lateral Raise")) + val result = ExerciseResolutionEngine.resolve("ab crnch machne", lib) + assertNotEquals(ResolutionStatus.UNRESOLVED, result.status) + assertNotNull(result.matched) + assertEquals("Ab Crunch Machine", result.matched?.name) + } + + @Test + fun lowSimilarityIsUnresolved() { + val lib = listOf(ex("1", "Barbell Bench Press"), ex("2", "Seated Cable Row")) + val result = ExerciseResolutionEngine.resolve("Underwater dolphin kick", lib) + assertEquals(ResolutionStatus.UNRESOLVED, result.status) + assertTrue(result.confidence < 70) + } + + private fun ex(id: String, name: String) = LegacyExerciseShape( + id = id, + exerciseId = id, + name = name, + primaryMuscles = listOf("Chest"), + primaryMuscle = "Chest", + secondaryMuscles = emptyList(), + equipment = "Machine", + category = "strength", + trackingType = "weight_reps", + isCustom = false, + aliases = emptyList(), + isBodyweight = false, + movementPattern = "isolation", + difficulty = "beginner", + apparatus = null, + equipmentDetail = null, + sourceTags = emptyList(), + notes = "", + ) +} diff --git a/app/src/test/java/com/ironlog/app/domain/ai/SmartPlanStructuringEngineTest.kt b/app/src/test/java/com/ironlog/app/domain/ai/SmartPlanStructuringEngineTest.kt new file mode 100644 index 0000000..65fa81f --- /dev/null +++ b/app/src/test/java/com/ironlog/app/domain/ai/SmartPlanStructuringEngineTest.kt @@ -0,0 +1,117 @@ +package com.ironlog.app.domain.ai + +import com.ironlog.app.data.model.FullPlanDay +import com.ironlog.app.data.model.FullPlanObject +import com.ironlog.app.data.model.LegacyExerciseShape +import com.ironlog.app.data.model.PlanExerciseInput +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SmartPlanStructuringEngineTest { + @Test + fun addsWarmupForFirstCompoundWhenMissing() { + val plan = FullPlanObject( + name = "P", + days = listOf( + FullPlanDay( + name = "Push", + exercises = listOf( + PlanExerciseInput(exerciseId = "bench", name = "Barbell Bench Press", sets = 4, reps = "6-8", restSeconds = 20, isWarmup = false), + PlanExerciseInput(exerciseId = "lat", name = "Cable Lateral Raise", sets = 4, reps = "12-15", restSeconds = 0, isWarmup = false), + ), + ) + ), + ) + val lib = listOf( + exercise("bench", "Barbell Bench Press", "push", "Barbell", "Chest", "weight_reps"), + exercise("lat", "Cable Lateral Raise", "isolation", "Cable", "Shoulders", "weight_reps"), + ) + + val out = SmartPlanStructuringEngine.structure(plan, lib) + val day = out.plan.days.first() + assertTrue(out.insertedWarmupRows >= 1) + assertTrue(day.exercises.first().isWarmup == true) + assertEquals(60, day.exercises.first().restSeconds) + assertEquals(120, day.exercises[1].restSeconds) // normalized compound rest (clamped from 20) + } + + @Test + fun suggestsSupersetForAdjacentIsolationRows() { + val plan = FullPlanObject( + name = "P", + days = listOf( + FullPlanDay( + name = "Pull", + exercises = listOf( + PlanExerciseInput(exerciseId = "curl", name = "Incline Dumbbell Curl", sets = 4, reps = "10-12", restSeconds = 75, isWarmup = false), + PlanExerciseInput(exerciseId = "pushdown", name = "Rope Pushdown", sets = 4, reps = "10-12", restSeconds = 75, isWarmup = false), + ), + ) + ), + ) + val lib = listOf( + exercise("curl", "Incline Dumbbell Curl", "isolation", "Dumbbell", "Biceps", "weight_reps"), + exercise("pushdown", "Rope Pushdown", "isolation", "Cable", "Triceps", "weight_reps"), + ) + + val out = SmartPlanStructuringEngine.structure(plan, lib) + val rows = out.plan.days.first().exercises + assertEquals(rows[0].supersetGroup, rows[1].supersetGroup) + assertTrue(rows[0].supersetGroup == "A") + } + + @Test + fun keepsTimeBasedRowsOutOfSupersetAndNormalizesRest() { + val plan = FullPlanObject( + name = "P", + days = listOf( + FullPlanDay( + name = "Core", + exercises = listOf( + PlanExerciseInput(exerciseId = "plank", name = "Plank", sets = 3, reps = "60s", restSeconds = 200, isWarmup = false), + PlanExerciseInput(exerciseId = "crunch", name = "Ab Crunch Machine", sets = 4, reps = "12-15", restSeconds = 5, isWarmup = false), + ), + ) + ), + ) + val lib = listOf( + exercise("plank", "Plank", "isolation", "Bodyweight", "Core", "duration"), + exercise("crunch", "Ab Crunch Machine", "isolation", "Machine", "Core", "weight_reps"), + ) + + val out = SmartPlanStructuringEngine.structure(plan, lib) + val rows = out.plan.days.first().exercises + assertEquals(75, rows[0].restSeconds) + assertEquals(60, rows[1].restSeconds) + assertTrue(rows.all { it.supersetGroup.isNullOrBlank() }) + } + + private fun exercise( + id: String, + name: String, + movementPattern: String, + equipment: String, + primaryMuscle: String, + trackingType: String, + ) = LegacyExerciseShape( + id = id, + exerciseId = id, + name = name, + primaryMuscles = listOf(primaryMuscle), + primaryMuscle = primaryMuscle, + secondaryMuscles = emptyList(), + equipment = equipment, + category = "strength", + trackingType = trackingType, + isCustom = false, + aliases = emptyList(), + isBodyweight = equipment.equals("bodyweight", ignoreCase = true), + movementPattern = movementPattern, + difficulty = "intermediate", + apparatus = null, + equipmentDetail = null, + sourceTags = emptyList(), + notes = "", + ) +} diff --git a/app/src/test/java/com/ironlog/app/domain/badges/BadgeDefinitionsTest.kt b/app/src/test/java/com/ironlog/app/domain/badges/BadgeDefinitionsTest.kt new file mode 100644 index 0000000..11328c0 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/domain/badges/BadgeDefinitionsTest.kt @@ -0,0 +1,51 @@ +package com.ironlog.app.domain.badges + +import org.junit.Assert.* +import org.junit.Test + +class BadgeDefinitionsTest { + + @Test fun `all badges have unique ids`() { + val ids = BadgeDefinitions.all.map { it.id } + assertEquals(ids.size, ids.toSet().size) + } + + @Test fun `all four tiers are present`() { + val tiers = BadgeDefinitions.all.map { it.tier }.toSet() + assertEquals(setOf(BadgeTier.BRONZE, BadgeTier.SILVER, BadgeTier.GOLD, BadgeTier.BLUE), tiers) + } + + @Test fun `first_workout unlocks after 1 workout`() { + val stats = AppStats(totalWorkouts = 1) + assertTrue("first_workout" in BadgeDefinitions.evaluate(stats)) + } + + @Test fun `no badges unlock for a fresh user`() { + val stats = AppStats() + assertTrue(BadgeDefinitions.evaluate(stats).isEmpty()) + } + + @Test fun `streak_3 does not unlock at streak 2`() { + val stats = AppStats(currentStreak = 2) + assertFalse("streak_3" in BadgeDefinitions.evaluate(stats)) + } + + @Test fun `streak_3 unlocks at streak 3`() { + val stats = AppStats(currentStreak = 3) + assertTrue("streak_3" in BadgeDefinitions.evaluate(stats)) + } + + @Test fun `s_rank badge unlocks only for S rank`() { + assertFalse("s_rank" in BadgeDefinitions.evaluate(AppStats(currentRank = "A"))) + assertTrue("s_rank" in BadgeDefinitions.evaluate(AppStats(currentRank = "S"))) + } + + @Test fun `evaluate returns correct set for multiple stats`() { + val stats = AppStats(totalWorkouts = 1, usedRestTimer = true, createdPlan = true) + val earned = BadgeDefinitions.evaluate(stats) + assertTrue("first_workout" in earned) + assertTrue("first_rest_timer" in earned) + assertTrue("first_plan" in earned) + assertFalse("workouts_10" in earned) + } +} diff --git a/app/src/test/java/com/ironlog/app/domain/gamification/GamificationSummaryTest.kt b/app/src/test/java/com/ironlog/app/domain/gamification/GamificationSummaryTest.kt new file mode 100644 index 0000000..18c74fe --- /dev/null +++ b/app/src/test/java/com/ironlog/app/domain/gamification/GamificationSummaryTest.kt @@ -0,0 +1,72 @@ +package com.ironlog.app.domain.gamification + +import com.ironlog.app.assets.ForgeFoxExpression +import com.ironlog.app.ui.model.HistoryEntry +import org.junit.Assert.assertEquals +import org.junit.Test + +class GamificationSummaryTest { + @Test + fun `active workout state resumes current session`() { + val summary = buildDailyProofSummary( + history = emptyList(), + hasActivePlan = true, + activeWorkoutDayName = "Push Day", + readinessScore = 82, + nowEpochMs = 1_779_696_000_000L, + ) + + assertEquals(DailyProofStatus.ACTIVE_WORKOUT, summary.status) + assertEquals("Resume workout", summary.primaryActionLabel) + assertEquals("ActiveWorkout", summary.primaryRoute) + assertEquals(ForgeFoxExpression.Determined.id, summary.foxExpressionId) + } + + @Test + fun `fresh user without plan is routed to setup`() { + val summary = buildDailyProofSummary( + history = emptyList(), + hasActivePlan = false, + activeWorkoutDayName = null, + readinessScore = null, + nowEpochMs = 1_779_696_000_000L, + ) + + assertEquals(DailyProofStatus.SETUP, summary.status) + assertEquals("Choose a program", summary.primaryActionLabel) + assertEquals("ProgramPicker", summary.primaryRoute) + assertEquals(ForgeFoxExpression.Clipboard.id, summary.foxExpressionId) + } + + @Test + fun `training completed today becomes saved proof state`() { + val summary = buildDailyProofSummary( + history = listOf(HistoryEntry(id = "today", date = "2026-05-25T10:00:00Z")), + hasActivePlan = true, + activeWorkoutDayName = null, + readinessScore = 91, + nowEpochMs = 1_779_696_000_000L, + ) + + assertEquals(DailyProofStatus.PROOF_LOGGED, summary.status) + assertEquals("Open Iron Ledger", summary.primaryActionLabel) + assertEquals("statusWindow", summary.primaryRoute) + assertEquals(ForgeFoxExpression.Proud.id, summary.foxExpressionId) + } + + @Test + fun `stale training history becomes at risk state`() { + val summary = buildDailyProofSummary( + history = listOf(HistoryEntry(id = "old", date = "2026-05-20T10:00:00Z")), + hasActivePlan = true, + activeWorkoutDayName = null, + readinessScore = 61, + nowEpochMs = 1_779_696_000_000L, + ) + + assertEquals(DailyProofStatus.AT_RISK, summary.status) + assertEquals("Train today", summary.primaryActionLabel) + assertEquals("Home", summary.primaryRoute) + assertEquals(ForgeFoxExpression.CheckingWatch.id, summary.foxExpressionId) + } +} diff --git a/app/src/test/java/com/ironlog/app/domain/gamification/IronLedgerBaselineSeedingTest.kt b/app/src/test/java/com/ironlog/app/domain/gamification/IronLedgerBaselineSeedingTest.kt new file mode 100644 index 0000000..da34f52 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/domain/gamification/IronLedgerBaselineSeedingTest.kt @@ -0,0 +1,77 @@ +package com.ironlog.app.domain.gamification + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class IronLedgerBaselineSeedingTest { + private val engine = IronLedgerEngine() + + @Test + fun `onboarding calibration seeds starting ledger grade and stats before workout history`() { + val snapshot = engine.rebuild( + history = emptyList(), + weeklyGoal = 5, + calibration = AthleteCalibration( + trainingAgeMonths = 19, + weeklyGoalDays = 5, + historicalTrainingDaysPerWeek = 4, + bodyweightKg = 70.0, + hasPastTraining = true, + hasGymAccess = true, + baselinePushups = 40, + baselinePullups = 14, + baselineBenchKg = 65, + baselineLatPulldownKg = 110, + baselineMileRunSeconds = 570, + ), + ) + + assertEquals(IronGrade.TITANIUM, snapshot.grade) + assertTrue(snapshot.stats.strength > 1) + assertTrue(snapshot.stats.power > 1) + assertTrue(snapshot.stats.endurance > 1) + assertTrue(snapshot.stats.agility > 1) + assertTrue(snapshot.stats.discipline > 1) + } + + @Test + fun `historical training frequency changes seeded baseline rank and stats`() { + val lowFrequency = engine.rebuild( + history = emptyList(), + weeklyGoal = 5, + calibration = AthleteCalibration( + trainingAgeMonths = 24, + weeklyGoalDays = 5, + historicalTrainingDaysPerWeek = 1, + bodyweightKg = 70.0, + hasPastTraining = true, + hasGymAccess = true, + baselinePushups = 18, + baselinePullups = 5, + baselineBenchKg = 45, + baselineLatPulldownKg = 60, + ), + ) + val highFrequency = engine.rebuild( + history = emptyList(), + weeklyGoal = 5, + calibration = AthleteCalibration( + trainingAgeMonths = 24, + weeklyGoalDays = 5, + historicalTrainingDaysPerWeek = 5, + bodyweightKg = 70.0, + hasPastTraining = true, + hasGymAccess = true, + baselinePushups = 18, + baselinePullups = 5, + baselineBenchKg = 45, + baselineLatPulldownKg = 60, + ), + ) + + assertTrue(highFrequency.grade.ordinal >= lowFrequency.grade.ordinal) + assertTrue(highFrequency.stats.discipline > lowFrequency.stats.discipline) + assertTrue(highFrequency.stats.recovery > lowFrequency.stats.recovery) + } +} diff --git a/app/src/test/java/com/ironlog/app/domain/gamification/IronLedgerEngineTest.kt b/app/src/test/java/com/ironlog/app/domain/gamification/IronLedgerEngineTest.kt new file mode 100644 index 0000000..eda66e8 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/domain/gamification/IronLedgerEngineTest.kt @@ -0,0 +1,240 @@ +package com.ironlog.app.domain.gamification + +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.HistoryExercise +import com.ironlog.app.ui.model.HistoryExerciseSet +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class IronLedgerEngineTest { + private val engine = IronLedgerEngine() + + private fun set( + id: String, + weight: Double = 100.0, + reps: Double = 8.0, + type: String = "normal", + rpe: Double? = 8.0, + ) = HistoryExerciseSet( + id = id, + weight = weight, + reps = reps, + type = type, + rpe = rpe, + ) + + private fun workout( + id: String, + date: String, + durationMin: Int = 60, + imported: Boolean = false, + exercises: List = listOf( + HistoryExercise( + id = "$id-ex1", + exerciseId = "bench", + name = "Bench Press", + primaryMuscle = "Chest", + category = "strength", + sets = listOf(set("$id-s1"), set("$id-s2"), set("$id-s3")), + ), + HistoryExercise( + id = "$id-ex2", + exerciseId = "row", + name = "Cable Row", + primaryMuscle = "Back", + category = "strength", + sets = listOf(set("$id-s4"), set("$id-s5"), set("$id-s6")), + ), + HistoryExercise( + id = "$id-ex3", + exerciseId = "squat", + name = "Squat", + primaryMuscle = "Quads", + category = "strength", + sets = listOf(set("$id-s7"), set("$id-s8")), + ), + ), + ) = HistoryEntry( + id = id, + date = date, + duration = durationMin * 60, + imported = imported, + exercises = exercises, + ) + + @Test + fun `warmups do not create qualifying workout xp`() { + val history = listOf( + workout( + id = "w1", + date = "2026-05-01T10:00:00Z", + exercises = listOf( + HistoryExercise( + id = "warmups", + exerciseId = "bench", + name = "Bench Press", + primaryMuscle = "Chest", + category = "strength", + sets = List(10) { set("warmup-$it", type = "warmup") }, + ) + ), + ) + ) + + val snapshot = engine.rebuild(history, weeklyGoal = 4, calibration = AthleteCalibration()) + + assertEquals(0L, snapshot.totalXp) + assertEquals(IronGrade.UNCALIBRATED, snapshot.grade) + } + + @Test + fun `same day session spam is capped and lowers integrity`() { + val spam = (1..6).map { + workout( + id = "w$it", + date = "2026-05-01T1$it:00:00Z", + durationMin = 45, + ) + } + + val snapshot = engine.rebuild(spam, weeklyGoal = 4, calibration = AthleteCalibration()) + + assertTrue("Daily cap should keep XP below six full sessions", snapshot.totalXp < 6L * 40L) + assertTrue("Session spam should reduce integrity", snapshot.integrityScore < 0.9) + } + + @Test + fun `imported history does not count as verified grade proof`() { + val imported = (1..800).map { + workout( + id = "w$it", + date = "2026-05-${((it - 1) % 28 + 1).toString().padStart(2, '0')}T10:00:00Z", + imported = true, + ) + } + + val snapshot = engine.rebuild( + history = imported, + weeklyGoal = 4, + calibration = AthleteCalibration(importedHistory = true), + ) + + assertTrue(snapshot.grade.ordinal < IronGrade.APEX.ordinal) + assertEquals(0, snapshot.verifiedSessions) + } + + @Test + fun `imported history does not reduce trust for later native workouts`() { + val imported = workout( + id = "imported", + date = "2026-04-01T10:00:00Z", + imported = true, + ) + val native = workout( + id = "native", + date = "2026-05-01T10:00:00Z", + ) + + val snapshot = engine.rebuild( + history = listOf(imported, native), + weeklyGoal = 1, + calibration = AthleteCalibration(importedHistory = true), + ) + + val importedTrust = snapshot.events.first { it.sourceId == "imported" }.trust + val nativeTrust = snapshot.events.first { it.sourceId == "native" }.trust + assertEquals(0.55, importedTrust, 0.0001) + assertEquals(1.0, nativeTrust, 0.0001) + assertEquals(1, snapshot.verifiedSessions) + } + + @Test + fun `stat attribution rewards cardio as endurance not strength`() { + val cardio = workout( + id = "run", + date = "2026-05-01T10:00:00Z", + exercises = listOf( + HistoryExercise( + id = "run-ex", + exerciseId = "running", + name = "Running", + primaryMuscle = "Cardio", + category = "cardio", + sets = listOf(set("run-set", weight = 0.0, reps = 1800.0)), + ) + ), + ) + + val snapshot = engine.rebuild(listOf(cardio), weeklyGoal = 3, calibration = AthleteCalibration()) + + assertTrue(snapshot.stats.endurance > snapshot.stats.strength) + } + + @Test + fun `material grade names replace solo ranks`() { + assertEquals("Graphite", IronGrade.GRAPHITE.label) + assertEquals("Apex", IronGrade.APEX.label) + } + + @Test + fun `repeated exercise blocks create distinct ledger source ids`() { + val repeatedBlocks = workout( + id = "w-repeat", + date = "2026-05-01T10:00:00Z", + exercises = listOf( + HistoryExercise( + id = "bench-a", + exerciseId = "bench", + name = "Bench Press", + primaryMuscle = "Chest", + category = "strength", + sets = listOf(set("a1", weight = 100.0), set("a2", weight = 100.0), set("a3", weight = 100.0)), + ), + HistoryExercise( + id = "bench-b", + exerciseId = "bench", + name = "Bench Press", + primaryMuscle = "Chest", + category = "strength", + sets = listOf(set("b1", weight = 110.0), set("b2", weight = 110.0), set("b3", weight = 110.0)), + ), + HistoryExercise( + id = "bench-c", + exerciseId = "bench", + name = "Bench Press", + primaryMuscle = "Chest", + category = "strength", + sets = listOf(set("c1", weight = 120.0), set("c2", weight = 120.0), set("c3", weight = 120.0)), + ), + ), + ) + + val prSourceIds = engine.rebuild(listOf(repeatedBlocks), weeklyGoal = 4, calibration = AthleteCalibration()) + .events + .filter { it.kind == "pr" } + .map { it.sourceId } + + assertTrue(prSourceIds.size >= 2) + assertEquals(prSourceIds.size, prSourceIds.distinct().size) + } + + @Test + fun `local date strings still count toward tenure and qualifying weeks`() { + val history = listOf( + workout(id = "w1", date = "2026-05-01"), + workout(id = "w2", date = "2026-05-08"), + workout(id = "w3", date = "2026-05-15"), + workout(id = "w4", date = "2026-05-22"), + ) + + val snapshot = engine.rebuild( + history = history, + weeklyGoal = 1, + calibration = AthleteCalibration(), + ) + + assertTrue(snapshot.qualifyingWeeks >= 4) + assertTrue(snapshot.tenureDays >= 21) + } +} diff --git a/app/src/test/java/com/ironlog/app/domain/gamification/StatEngineTest.kt b/app/src/test/java/com/ironlog/app/domain/gamification/StatEngineTest.kt new file mode 100644 index 0000000..8b1ab14 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/domain/gamification/StatEngineTest.kt @@ -0,0 +1,72 @@ +// app/src/test/java/com/ironlog/app/domain/gamification/StatEngineTest.kt +package com.ironlog.app.domain.gamification + +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.HistoryExercise +import com.ironlog.app.ui.model.HistoryExerciseSet +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class StatEngineTest { + + private val engine = StatEngine() + + private fun heavySet(weight: Double, reps: Double) = HistoryExerciseSet( + id = "s1", weight = weight, reps = reps, type = "normal" + ) + + private fun entry( + id: String, + date: String, + durationMin: Int = 60, + exerciseCount: Int = 5, + sets: List = listOf(heavySet(100.0, 5.0)), + ) = HistoryEntry( + id = id, + date = date, + duration = durationMin * 60, + exercises = (1..exerciseCount).map { i -> + HistoryExercise(id = "$id-ex$i", name = "Exercise $i", sets = sets) + }, + ) + + @Test fun `empty history returns all stats at minimum 1`() { + val stats = engine.compute(emptyList(), streak = 0, totalSessions = 0) + assertTrue(stats.str >= 1) + assertTrue(stats.vit >= 1) + assertTrue(stats.end >= 1) + assertTrue(stats.agi >= 1) + assertTrue(stats.wis >= 1) + assertTrue(stats.luk >= 1) + } + + @Test fun `all stats are capped at 999`() { + // Generate 500 heavy workouts + val history = (1..500).map { i -> + entry(id = "e$i", date = "2026-01-${(i % 28 + 1).toString().padStart(2, '0')}", + sets = listOf(heavySet(200.0, 5.0))) + } + val stats = engine.compute(history, streak = 500, totalSessions = 500) + assertTrue(stats.str <= 999) + assertTrue(stats.vit <= 999) + assertTrue(stats.end <= 999) + assertTrue(stats.agi <= 999) + } + + @Test fun `higher estimated 1RM gives higher STR`() { + val light = listOf(entry("a", "2026-05-01", sets = listOf(heavySet(50.0, 10.0)))) + val heavy = listOf(entry("b", "2026-05-01", sets = listOf(heavySet(150.0, 5.0)))) + val statsLight = engine.compute(light, streak = 0, totalSessions = 1) + val statsHeavy = engine.compute(heavy, streak = 0, totalSessions = 1) + assertTrue("Heavier lifter should have more STR", statsHeavy.str > statsLight.str) + } + + @Test fun `longer workout duration gives higher END`() { + val short = listOf(entry("a", "2026-05-01", durationMin = 20)) + val long = listOf(entry("b", "2026-05-01", durationMin = 120)) + val statsShort = engine.compute(short, streak = 0, totalSessions = 1) + val statsLong = engine.compute(long, streak = 0, totalSessions = 1) + assertTrue("Longer workouts should give more END", statsLong.end > statsShort.end) + } +} diff --git a/app/src/test/java/com/ironlog/app/domain/gamification/StreakEngineTest.kt b/app/src/test/java/com/ironlog/app/domain/gamification/StreakEngineTest.kt new file mode 100644 index 0000000..cb869f6 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/domain/gamification/StreakEngineTest.kt @@ -0,0 +1,78 @@ +package com.ironlog.app.domain.gamification + +import com.ironlog.app.ui.model.HistoryEntry +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDate + +class StreakEngineTest { + + private val engine = StreakEngine() + + private fun entry(date: LocalDate) = HistoryEntry( + id = date.toString(), + date = date.toString(), + ) + + // Week of 2026-05-18 is ISO week 21 (Mon May 18 - Sun May 24). + private val monday = LocalDate.of(2026, 5, 18) + + @Test fun `empty history gives streak 0`() { + assertEquals(0, engine.computeStreakWeeks(emptyList(), weeklyGoal = 4, recoveryCircuitCompletions = emptyMap())) + } + + @Test fun `one qualifying week gives streak 1`() { + val history = (0..3).map { entry(monday.plusDays(it.toLong())) } + assertEquals(1, engine.computeStreakWeeks(history, 4, emptyMap(), monday.plusDays(3))) + } + + @Test fun `two consecutive qualifying weeks give streak 2`() { + val week1 = (0..3).map { entry(monday.plusDays(it.toLong())) } + val week2 = (7..10).map { entry(monday.plusDays(it.toLong())) } + val history = week1 + week2 + assertEquals(2, engine.computeStreakWeeks(history, 4, emptyMap(), monday.plusDays(10))) + } + + @Test fun `gap week without recovery circuit breaks streak`() { + val week1 = (0..3).map { entry(monday.plusDays(it.toLong())) } + val week2 = (7..9).map { entry(monday.plusDays(it.toLong())) } + val week3 = (14..17).map { entry(monday.plusDays(it.toLong())) } + val history = week1 + week2 + week3 + val result = engine.computeStreakWeeks(history, 4, emptyMap(), monday.plusDays(17)) + assertEquals(1, result) + } + + @Test fun `gap week with recovery circuit saves streak`() { + val week1 = (0..3).map { entry(monday.plusDays(it.toLong())) } + val week2 = (7..9).map { entry(monday.plusDays(it.toLong())) } + val week3 = (14..17).map { entry(monday.plusDays(it.toLong())) } + val history = week1 + week2 + week3 + val recoveryCircuits = mapOf("2026-W22" to 1) + val result = engine.computeStreakWeeks(history, 4, recoveryCircuits, monday.plusDays(17)) + assertEquals(3, result) + } + + @Test fun `recovery circuit does not save when sessions is goal minus 2 or more short`() { + val week1 = (0..3).map { entry(monday.plusDays(it.toLong())) } + val week2 = (7..8).map { entry(monday.plusDays(it.toLong())) } + val week3 = (14..17).map { entry(monday.plusDays(it.toLong())) } + val history = week1 + week2 + week3 + val recoveryCircuits = mapOf("2026-W22" to 1) + val result = engine.computeStreakWeeks(history, 4, recoveryCircuits, monday.plusDays(17)) + assertEquals(1, result) + } + + @Test fun `old qualifying history is not reported as a current streak`() { + val history = (0..3).map { entry(monday.plusDays(it.toLong())) } + assertEquals(0, engine.computeStreakWeeks(history, 4, emptyMap(), monday.plusWeeks(3))) + } + + @Test fun `unfinished current week preserves immediately previous streak`() { + val previousWeek = (0..3).map { entry(monday.plusDays(it.toLong())) } + val currentWeekPartial = listOf(entry(monday.plusWeeks(1))) + assertEquals( + 1, + engine.computeStreakWeeks(previousWeek + currentWeekPartial, 4, emptyMap(), monday.plusWeeks(1).plusDays(1)), + ) + } +} diff --git a/app/src/test/java/com/ironlog/app/domain/gamification/XpEngineTest.kt b/app/src/test/java/com/ironlog/app/domain/gamification/XpEngineTest.kt new file mode 100644 index 0000000..5a7c7f7 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/domain/gamification/XpEngineTest.kt @@ -0,0 +1,61 @@ +package com.ironlog.app.domain.gamification + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class XpEngineTest { + + private val engine = XpEngine() + + @Test fun `level 1 requires 125 XP`() { + assertEquals(125L, engine.xpForLevel(1)) + } + + @Test fun `level 2 requires 500 XP`() { + assertEquals(500L, engine.xpForLevel(2)) + } + + @Test fun `level 10 requires 12500 XP`() { + assertEquals(12500L, engine.xpForLevel(10)) + } + + @Test fun `xp curve is strictly increasing`() { + val xps = (1..20).map { engine.xpForLevel(it) } + for (i in 1 until xps.size) { + assertTrue("Level ${i + 1} xp should exceed level $i xp", xps[i] > xps[i - 1]) + } + } + + @Test fun `rank E for level 1`() { + assertEquals("E", engine.rankForLevel(1)) + } + + @Test fun `rank D for level 11`() { + assertEquals("D", engine.rankForLevel(11)) + } + + @Test fun `rank C for level 21`() { + assertEquals("C", engine.rankForLevel(21)) + } + + @Test fun `rank B for level 36`() { + assertEquals("B", engine.rankForLevel(36)) + } + + @Test fun `rank A for level 51`() { + assertEquals("A", engine.rankForLevel(51)) + } + + @Test fun `rank S for level 71`() { + assertEquals("S", engine.rankForLevel(71)) + } + + @Test fun `rank Apex for level 91`() { + assertEquals("Apex", engine.rankForLevel(91)) + } + + @Test fun `xpForAction RECOVERY_CIRCUIT returns positive value`() { + assertTrue(engine.xpForAction(XpAction.RECOVERY_CIRCUIT) > 0) + } +} diff --git a/app/src/test/java/com/ironlog/app/domain/intelligence/RecoveryReadinessEngineTest.kt b/app/src/test/java/com/ironlog/app/domain/intelligence/RecoveryReadinessEngineTest.kt new file mode 100644 index 0000000..f384c13 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/domain/intelligence/RecoveryReadinessEngineTest.kt @@ -0,0 +1,65 @@ +// app/src/test/java/com/ironlog/app/domain/intelligence/RecoveryReadinessEngineTest.kt +package com.ironlog.app.domain.intelligence + +import com.ironlog.app.data.health.BiometricSnapshot +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.HistoryExercise +import com.ironlog.app.ui.model.HistoryExerciseSet +import java.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecoveryReadinessEngineTest { + + @Test fun `blendWithBiometric returns training score when no biometrics`() { + val snap = BiometricSnapshot() // all null + val result = RecoveryReadinessEngine.blendWithBiometric(0.8, snap) + assertEquals(0.8, result, 0.001) + } + + @Test fun `blendWithBiometric blends down when sleep is poor`() { + val snap = BiometricSnapshot(sleepHours = 4.0) // worst sleep score + val result = RecoveryReadinessEngine.blendWithBiometric(1.0, snap) + assertTrue("Poor sleep should pull blend below 1.0", result < 1.0) + } + + @Test fun `blendWithBiometric is 1_0 when training and biometrics are both perfect`() { + val snap = BiometricSnapshot(sleepHours = 8.0, hrvRmssd = 80.0) + val result = RecoveryReadinessEngine.blendWithBiometric(1.0, snap) + assertEquals(1.0, result, 0.001) + } + + @Test fun `blendWithBiometric stays within 0_0 to 1_0`() { + val snap = BiometricSnapshot(sleepHours = 0.0, hrvRmssd = 0.0) + val result = RecoveryReadinessEngine.blendWithBiometric(0.0, snap) + assertTrue(result in 0.0..1.0) + } + + @Test fun `readiness can be evaluated against a historical clock`() { + val workoutAt = Instant.parse("2026-01-01T12:00:00Z") + val history = listOf( + HistoryEntry( + id = "w1", + date = workoutAt.toString(), + exercises = listOf( + HistoryExercise( + exerciseId = "bench", + name = "Bench Press", + primaryMuscle = "chest", + sets = listOf(HistoryExerciseSet(weight = 100.0, reps = 5.0)), + ) + ), + ) + ) + val immediatelyAfter = RecoveryReadinessEngine.readinessByRegion( + history, + nowEpochMs = workoutAt.plusSeconds(60).toEpochMilli(), + ) + val aWeekLater = RecoveryReadinessEngine.readinessByRegion( + history, + nowEpochMs = workoutAt.plusSeconds(7 * 24 * 3600).toEpochMilli(), + ) + assertTrue(aWeekLater.getValue("Push") > immediatelyAfter.getValue("Push")) + } +} diff --git a/app/src/test/java/com/ironlog/app/domain/intelligence/WorkoutSuggestionEngineTest.kt b/app/src/test/java/com/ironlog/app/domain/intelligence/WorkoutSuggestionEngineTest.kt new file mode 100644 index 0000000..bf8d3ba --- /dev/null +++ b/app/src/test/java/com/ironlog/app/domain/intelligence/WorkoutSuggestionEngineTest.kt @@ -0,0 +1,95 @@ +package com.ironlog.app.domain.intelligence + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WorkoutSuggestionEngineTest { + + private val engine = WorkoutSuggestionEngine() + + // ── regionForExercise ────────────────────────────────────────────────── + + @Test fun `bench press maps to Push`() { + assertEquals("Push", engine.regionForExercise("Bench Press")) + } + + @Test fun `barbell row maps to Pull`() { + assertEquals("Pull", engine.regionForExercise("Barbell Row")) + } + + @Test fun `squat maps to Legs`() { + assertEquals("Legs", engine.regionForExercise("Back Squat")) + } + + @Test fun `plank maps to Core`() { + assertEquals("Core", engine.regionForExercise("Plank")) + } + + @Test fun `curl maps to Arms`() { + assertEquals("Arms", engine.regionForExercise("Dumbbell Curl")) + } + + @Test fun `lateral raise maps to Shoulders`() { + assertEquals("Shoulders", engine.regionForExercise("Lateral Raise")) + } + + @Test fun `unknown exercise maps to null`() { + assertEquals(null, engine.regionForExercise("Juggling")) + } + + // ── scoreDay ────────────────────────────────────────────────────────── + + @Test fun `day with fully rested regions scores near 1`() { + val readiness = mapOf("Push" to 1.0, "Pull" to 1.0, "Legs" to 1.0) + val score = engine.scoreDay(readiness, listOf("Bench Press", "Pull Up", "Squat")) + assertTrue("score should be >= 0.9 but was $score", score >= 0.9) + } + + @Test fun `day with all fatigued regions scores near 0`() { + val readiness = mapOf("Push" to 0.0, "Pull" to 0.0, "Legs" to 0.0) + val score = engine.scoreDay(readiness, listOf("Bench Press", "Pull Up", "Squat")) + assertTrue("score should be <= 0.2 but was $score", score <= 0.2) + } + + @Test fun `day with no matched exercises scores 0_5 (neutral)`() { + val readiness = mapOf("Push" to 1.0) + val score = engine.scoreDay(readiness, listOf("Juggling", "Yoga Flow")) + assertEquals(0.5, score, 0.001) + } + + // ── suggestDayIndex ──────────────────────────────────────────────────── + + @Test fun `returns index of highest-scoring day`() { + val readiness = mapOf("Push" to 1.0, "Legs" to 0.1) + val dayExerciseNames = listOf( + listOf("Squat", "Leg Press"), // day 0 — Legs fatigued → low score + listOf("Bench Press", "Dip"), // day 1 — Push fresh → high score + ) + val result = engine.suggestDayIndex(readiness, dayExerciseNames) + assertEquals(1, result) + } + + @Test fun `returns 0 when all days equally ready`() { + val readiness = mapOf("Push" to 1.0, "Pull" to 1.0) + val dayExerciseNames = listOf( + listOf("Bench Press"), + listOf("Pull Up"), + ) + val result = engine.suggestDayIndex(readiness, dayExerciseNames) + // Either is valid; ensure it returns a valid index + assertTrue(result in 0..1) + } + + @Test fun `returns 0 for empty day list`() { + assertEquals(0, engine.suggestDayIndex(emptyMap(), emptyList())) + } + + // ── recommendationBlurb ─────────────────────────────────────────────── + + @Test fun `blurb mentions the day name`() { + val readiness = mapOf("Push" to 0.9, "Legs" to 0.3) + val blurb = engine.recommendationBlurb(readiness, "Push Day") + assertTrue(blurb.contains("Push Day", ignoreCase = true)) + } +} diff --git a/app/src/test/java/com/ironlog/app/domain/sharing/PlanQrCodecTest.kt b/app/src/test/java/com/ironlog/app/domain/sharing/PlanQrCodecTest.kt new file mode 100644 index 0000000..95c43cf --- /dev/null +++ b/app/src/test/java/com/ironlog/app/domain/sharing/PlanQrCodecTest.kt @@ -0,0 +1,80 @@ +package com.ironlog.app.domain.sharing + +import com.ironlog.app.ui.model.UiPlan +import com.ironlog.app.ui.model.UiPlanDay +import com.ironlog.app.ui.model.UiPlanExercise +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlanQrCodecTest { + + private val codec = PlanQrCodec() + + private fun makePlan(dayCount: Int = 3, exercisesPerDay: Int = 5) = UiPlan( + id = "plan-1", + name = "Test Plan", + goal = "Strength", + description = "Test", + days = (1..dayCount).map { d -> + UiPlanDay( + id = "day-$d", + name = "Day $d", + exercises = (1..exercisesPerDay).map { e -> + UiPlanExercise( + id = "ex-$d-$e", + exerciseId = "exercise-$e", + name = "Exercise $e", + sets = 4, + reps = "8-12", + restSeconds = 90, + ) + }, + ) + }, + ) + + @Test fun `encodeToPaylod produces non-empty string`() { + val payload = codec.encodeToPayload(makePlan()) + assertTrue(payload.isNotBlank()) + } + + @Test fun `decodeFromPayload round-trips the plan`() { + val original = makePlan() + val payload = codec.encodeToPayload(original) + val decoded = codec.decodeFromPayload(payload) + assertNotNull(decoded) + assertEquals(original.name, decoded!!.name) + assertEquals(original.days.size, decoded.days.size) + assertEquals(original.days[0].exercises.size, decoded.days[0].exercises.size) + assertEquals(original.days[0].exercises[0].name, decoded.days[0].exercises[0].name) + } + + @Test fun `payload for 7-day plan fits QR v40 limit (4296 chars)`() { + val bigPlan = makePlan(dayCount = 7, exercisesPerDay = 8) + val payload = codec.encodeToPayload(bigPlan) + assertTrue("Payload length ${payload.length} exceeds QR v40 limit of 4296", + payload.length <= 4296) + } + + @Test fun `decodeFromPayload returns null for garbage input`() { + val decoded = codec.decodeFromPayload("not-valid-base64!@#$") + assertEquals(null, decoded) + } + + @Test fun `decodeFromPayload returns null for empty string`() { + assertEquals(null, codec.decodeFromPayload("")) + } + + @Test fun `decodeFromPayload rejects oversized encoded input before decoding`() { + assertEquals(null, codec.decodeFromPayload("A".repeat(8_193))) + } + + @Test fun `decodeFromPayload rejects unsafe plan structure`() { + val invalid = makePlan().copy(days = makePlan().days.map { day -> + day.copy(exercises = day.exercises.map { it.copy(sets = 0) }) + }) + assertEquals(null, codec.decodeFromPayload(codec.encodeToPayload(invalid))) + } +} diff --git a/app/src/test/java/com/ironlog/app/navigation/OnboardingPersistenceTest.kt b/app/src/test/java/com/ironlog/app/navigation/OnboardingPersistenceTest.kt new file mode 100644 index 0000000..2d93666 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/navigation/OnboardingPersistenceTest.kt @@ -0,0 +1,61 @@ +package com.ironlog.app.navigation + +import com.ironlog.app.data.objectbox.AthleteCalibrationEntity +import com.ironlog.app.ui.screens.onboarding.OnboardingDraft +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class OnboardingPersistenceTest { + @Test + fun `onboarding draft maps into canonical athlete calibration entity and fallback settings`() { + val draft = OnboardingDraft( + weeklyGoalDays = 5, + historicalTrainingDaysPerWeek = 4, + weightUnit = "kg", + goalMode = "STRENGTH", + bodyweightKg = 70, + trainingAgeMonths = 19, + hasPastTraining = true, + hasGymAccess = true, + baselinePushups = 40, + baselinePullups = 14, + baselineBenchKg = 65, + baselineLatPulldownKg = 110, + baselineMileRunSeconds = 570, + ) + + val entity = buildCalibrationEntityFromOnboardingDraft( + draft = draft, + existing = AthleteCalibrationEntity(), + updatedAtMs = 1234L, + ) + val baselineSettings = onboardingBaselineSettingsFromDraft(draft) + + assertEquals("local", entity.offlineUserId) + assertEquals(19, entity.trainingAgeMonths) + assertEquals(5, entity.weeklyGoalDays) + assertEquals(4, entity.historicalTrainingDaysPerWeek) + assertEquals("kg", entity.weightUnit) + assertEquals(70.0, entity.bodyweightKg!!, 0.0) + assertTrue(entity.hasPastTraining) + assertTrue(entity.hasGymAccess) + assertEquals(40, entity.baselinePushups) + assertEquals(14, entity.baselinePullups) + assertEquals(65, entity.baselineBenchKg) + assertEquals(110, entity.baselineLatPulldownKg) + assertEquals(570, entity.baselineMileRunSeconds) + assertEquals(1234L, entity.updatedAt) + + assertEquals("19", baselineSettings["baseline_training_age_months"]) + assertEquals("4", baselineSettings["baseline_historical_training_days_per_week"]) + assertEquals("70", baselineSettings["baseline_bodyweight_kg"]) + assertEquals("40", baselineSettings["baseline_pushups"]) + assertEquals("14", baselineSettings["baseline_pullups"]) + assertEquals("65", baselineSettings["baseline_bench_kg"]) + assertEquals("110", baselineSettings["baseline_lat_pulldown_kg"]) + assertEquals("570", baselineSettings["baseline_mile_run_seconds"]) + assertEquals("true", baselineSettings["baseline_has_past_training"]) + assertEquals("true", baselineSettings["baseline_has_gym_access"]) + } +} diff --git a/app/src/test/java/com/ironlog/app/ui/screens/BodyMapTransformTest.kt b/app/src/test/java/com/ironlog/app/ui/screens/BodyMapTransformTest.kt new file mode 100644 index 0000000..2e7dcab --- /dev/null +++ b/app/src/test/java/com/ironlog/app/ui/screens/BodyMapTransformTest.kt @@ -0,0 +1,49 @@ +package com.ironlog.app.ui.screens.body + +import org.junit.Assert.assertEquals +import org.junit.Test + +class BodyMapTransformTest { + @Test + fun `top anchored body transform centers path without screen-size bias`() { + val canvasWidth = 600f + val viewBox = ViewBox(x = 0f, y = 0f, width = 600f, height = 1200f) + + val transform = computeBodyTransform( + canvasW = canvasWidth, + canvasH = 1200f, + viewBox = viewBox, + verticalAnchor = VerticalAnchor.TOP, + ) + + assertEquals(0.93f, transform.scale, 0.0001f) + assertEquals(21f, transform.offsetX, 0.0001f) + assertEquals(0f, transform.offsetY, 0.0001f) + } + + @Test + fun `canonical fit uses shared dimensions so front and back render at same scale`() { + val frontViewBox = ViewBox(x = 0f, y = 0f, width = 620f, height = 1200f) + val backViewBox = ViewBox(x = 0f, y = 0f, width = 560f, height = 1180f) + + val front = computeBodyTransform( + canvasW = 300f, + canvasH = 600f, + viewBox = frontViewBox, + fitWidth = 620f, + fitHeight = 1200f, + verticalAnchor = VerticalAnchor.TOP, + ) + val back = computeBodyTransform( + canvasW = 300f, + canvasH = 600f, + viewBox = backViewBox, + fitWidth = 620f, + fitHeight = 1200f, + verticalAnchor = VerticalAnchor.TOP, + ) + + assertEquals(front.scale, back.scale, 0.0001f) + assertEquals((300f - backViewBox.width * back.scale) / 2f, back.offsetX, 0.0001f) + } +} diff --git a/app/src/test/java/com/ironlog/app/ui/screens/ParityClosureLogicTest.kt b/app/src/test/java/com/ironlog/app/ui/screens/ParityClosureLogicTest.kt new file mode 100644 index 0000000..dc191ec --- /dev/null +++ b/app/src/test/java/com/ironlog/app/ui/screens/ParityClosureLogicTest.kt @@ -0,0 +1,65 @@ +package com.ironlog.app.ui.screens + +import com.ironlog.app.ui.screens.intelligence.recommendedPlanDayId +import com.ironlog.app.ui.screens.stats.parseHistoryEditTimestampOrNull +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.UiPlan +import com.ironlog.app.ui.model.UiPlanDay +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ParityClosureLogicTest { + @Test + fun recommendedPlanDayStartsFirstDayWhenNoHistoryMatches() { + val plan = planOf("push", "pull", "legs") + + val result = recommendedPlanDayId(plan, emptyList()) + + assertEquals("push", result) + } + + @Test + fun recommendedPlanDayAdvancesAfterLastCompletedPlanDay() { + val plan = planOf("push", "pull", "legs") + val history = listOf( + HistoryEntry(id = "w2", date = "2026-05-18T12:00:00Z", planDayUid = "pull"), + HistoryEntry(id = "w1", date = "2026-05-17T12:00:00Z", planDayUid = "push"), + ) + + val result = recommendedPlanDayId(plan, history) + + assertEquals("legs", result) + } + + @Test + fun recommendedPlanDayWrapsToFirstDay() { + val plan = planOf("push", "pull", "legs") + val history = listOf( + HistoryEntry(id = "w3", date = "2026-05-18T12:00:00Z", planDayUid = "legs"), + ) + + val result = recommendedPlanDayId(plan, history) + + assertEquals("push", result) + } + + @Test + fun strictHistoryEditTimestampRejectsInvalidInput() { + assertNull(parseHistoryEditTimestampOrNull("2026-99-99", "25:99")) + } + + @Test + fun strictHistoryEditTimestampParsesValidLocalInput() { + val parsed = parseHistoryEditTimestampOrNull("2026-05-18", "09:30") + + requireNotNull(parsed) + assert(parsed.contains("T")) + } + + private fun planOf(vararg ids: String) = UiPlan( + id = "plan", + name = "Plan", + days = ids.map { UiPlanDay(id = it, name = it) }, + ) +} diff --git a/app/src/test/java/com/ironlog/app/ui/screens/ProgressPhotoCompareLogicTest.kt b/app/src/test/java/com/ironlog/app/ui/screens/ProgressPhotoCompareLogicTest.kt new file mode 100644 index 0000000..4ac9900 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/ui/screens/ProgressPhotoCompareLogicTest.kt @@ -0,0 +1,62 @@ +package com.ironlog.app.ui.screens.body + +import com.ironlog.app.data.objectbox.ProgressPhotoEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.LocalDate +import java.time.ZoneId + +class ProgressPhotoCompareLogicTest { + + @Test + fun `selecting compare dates fills A then B then restarts on third distinct date`() { + val first = updateDateCompareSelection(null, null, LocalDate.of(2026, 5, 10)) + val second = updateDateCompareSelection(first.first, first.second, LocalDate.of(2026, 5, 17)) + val third = updateDateCompareSelection(second.first, second.second, LocalDate.of(2026, 5, 24)) + + assertEquals(LocalDate.of(2026, 5, 10), first.first) + assertNull(first.second) + assertEquals(LocalDate.of(2026, 5, 10), second.first) + assertEquals(LocalDate.of(2026, 5, 17), second.second) + assertEquals(LocalDate.of(2026, 5, 24), third.first) + assertNull(third.second) + } + + @Test + fun `tapping selected B clears only B`() { + val state = updateDateCompareSelection( + selectedA = LocalDate.of(2026, 5, 10), + selectedB = LocalDate.of(2026, 5, 17), + tappedDate = LocalDate.of(2026, 5, 17), + ) + + assertEquals(LocalDate.of(2026, 5, 10), state.first) + assertNull(state.second) + } + + @Test + fun `latest photo on selected compare date is chosen for side by side`() { + val rows = listOf( + photo("a1", LocalDate.of(2026, 5, 10), 9), + photo("a2", LocalDate.of(2026, 5, 10), 18), + photo("b1", LocalDate.of(2026, 5, 17), 7), + ) + + val pair = resolveDateComparePhotos( + rows = rows, + selectedA = LocalDate.of(2026, 5, 10), + selectedB = LocalDate.of(2026, 5, 17), + ) + + assertEquals("a2", pair.first?.uid) + assertEquals("b1", pair.second?.uid) + } + + private fun photo(uid: String, date: LocalDate, hour: Int): ProgressPhotoEntity = + ProgressPhotoEntity().apply { + this.uid = uid + this.fileUri = "content://$uid" + this.takenAt = date.atTime(hour, 0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + } +} diff --git a/app/src/test/java/com/ironlog/app/ui/screens/WorkoutExerciseBindingTest.kt b/app/src/test/java/com/ironlog/app/ui/screens/WorkoutExerciseBindingTest.kt new file mode 100644 index 0000000..442d4b7 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/ui/screens/WorkoutExerciseBindingTest.kt @@ -0,0 +1,24 @@ +package com.ironlog.app.ui.screens.workout + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WorkoutExerciseBindingTest { + @Test + fun mapsDuplicateExerciseIdsToDistinctWorkoutRowsInUiOrder() { + val rows = listOf( + WorkoutExerciseBinding(workoutExerciseId = "warmup-row", exerciseId = "bench", orderIndex = 0), + WorkoutExerciseBinding(workoutExerciseId = "working-row", exerciseId = "bench", orderIndex = 1), + WorkoutExerciseBinding(workoutExerciseId = "curl-row", exerciseId = "curl", orderIndex = 2), + ) + + val result = buildWorkoutExerciseIndexMap( + exerciseIdsInUiOrder = listOf("bench", "bench", "curl"), + workoutExerciseRows = rows, + ) + + assertEquals("warmup-row", result[0]) + assertEquals("working-row", result[1]) + assertEquals("curl-row", result[2]) + } +} diff --git a/app/src/test/java/com/ironlog/app/ui/screens/onboarding/OnboardingViewModelTest.kt b/app/src/test/java/com/ironlog/app/ui/screens/onboarding/OnboardingViewModelTest.kt new file mode 100644 index 0000000..61c9466 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/ui/screens/onboarding/OnboardingViewModelTest.kt @@ -0,0 +1,105 @@ +package com.ironlog.app.ui.screens.onboarding + +import org.junit.Assert.* +import org.junit.Test + +class OnboardingViewModelTest { + + @Test fun `initial draft has defaults`() { + val vm = OnboardingViewModel() + assertEquals("", vm.draft.value.userName) + assertEquals(3, vm.draft.value.weeklyGoalDays) + assertEquals(3, vm.draft.value.historicalTrainingDaysPerWeek) + assertEquals("kg", vm.draft.value.weightUnit) + assertEquals("LOCAL", vm.draft.value.intelligenceMode) + assertFalse(vm.draft.value.cameraGranted) + assertFalse(vm.draft.value.healthConnectGranted) + assertFalse(vm.draft.value.notificationsGranted) + } + + @Test fun `updateUserName preserves spaces and updates`() { + val vm = OnboardingViewModel() + vm.updateUserName(" Pranav ") + assertEquals(" Pranav ", vm.draft.value.userName) + } + + @Test fun `updateWeeklyGoalDays clamps to 1-7`() { + val vm = OnboardingViewModel() + vm.updateWeeklyGoalDays(0) + assertEquals(1, vm.draft.value.weeklyGoalDays) + vm.updateWeeklyGoalDays(8) + assertEquals(7, vm.draft.value.weeklyGoalDays) + vm.updateWeeklyGoalDays(5) + assertEquals(5, vm.draft.value.weeklyGoalDays) + } + + @Test fun `setting a valid API key sets intelligenceMode to AUTO`() { + val vm = OnboardingViewModel() + vm.updateCloudApiKey("AIzaFakeKeyForTest1234567890") + assertEquals("AUTO", vm.draft.value.intelligenceMode) + } + + @Test fun `clearing API key reverts intelligenceMode to LOCAL`() { + val vm = OnboardingViewModel() + vm.updateCloudApiKey("AIzaFakeKeyForTest1234567890") + vm.updateCloudApiKey("") + assertEquals("LOCAL", vm.draft.value.intelligenceMode) + } + + @Test fun `setClassification seeds goalMode default`() { + val vm = OnboardingViewModel() + vm.setClassification(progressionStyle = "UNDULATING", defaultGoalMode = "PERFORMANCE") + assertEquals("UNDULATING", vm.draft.value.progressionStyle) + assertEquals("PERFORMANCE", vm.draft.value.goalMode) + } + + @Test fun `setGoalMode overrides seeded default`() { + val vm = OnboardingViewModel() + vm.setClassification(progressionStyle = "LINEAR", defaultGoalMode = "STRENGTH") + vm.setGoalMode("HYPERTROPHY") + assertEquals("HYPERTROPHY", vm.draft.value.goalMode) + } + + @Test fun `permission flags update independently`() { + val vm = OnboardingViewModel() + vm.setCameraGranted(true) + assertTrue(vm.draft.value.cameraGranted) + assertFalse(vm.draft.value.healthConnectGranted) + vm.setHealthConnectGranted(true) + assertTrue(vm.draft.value.healthConnectGranted) + assertTrue(vm.draft.value.cameraGranted) + } + + @Test fun `toggleDayIndex adds and removes days, min 1 day`() { + val vm = OnboardingViewModel() + // Default is {0,2,4} + vm.toggleDayIndex(1) + assertTrue(1 in vm.draft.value.selectedDayIndices) + // Remove one of the defaults + vm.toggleDayIndex(0) + assertFalse(0 in vm.draft.value.selectedDayIndices) + // weeklyGoalDays follows set size + assertEquals(vm.draft.value.selectedDayIndices.size, vm.draft.value.weeklyGoalDays) + } + + @Test fun `toggleDayIndex cannot remove last selected day`() { + val vm = OnboardingViewModel() + // Remove all but one + vm.toggleDayIndex(0) + vm.toggleDayIndex(4) + // Now only index 2 remains. Try to remove it. + vm.toggleDayIndex(2) + assertTrue(2 in vm.draft.value.selectedDayIndices) + assertEquals(1, vm.draft.value.selectedDayIndices.size) + } + + @Test fun `historical training days per week clamps to 1 through 7`() { + val vm = OnboardingViewModel() + vm.updateHistoricalTrainingDaysPerWeek(0) + assertEquals(1, vm.draft.value.historicalTrainingDaysPerWeek) + vm.updateHistoricalTrainingDaysPerWeek(9) + assertEquals(7, vm.draft.value.historicalTrainingDaysPerWeek) + vm.updateHistoricalTrainingDaysPerWeek(5) + assertEquals(5, vm.draft.value.historicalTrainingDaysPerWeek) + } +} diff --git a/app/src/test/java/com/ironlog/app/ui/screens/recovery/RecoveryBodyMapLayoutTest.kt b/app/src/test/java/com/ironlog/app/ui/screens/recovery/RecoveryBodyMapLayoutTest.kt new file mode 100644 index 0000000..7da1216 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/ui/screens/recovery/RecoveryBodyMapLayoutTest.kt @@ -0,0 +1,20 @@ +package com.ironlog.app.ui.screens.recovery + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecoveryBodyMapLayoutTest { + @Test + fun `recovery map page leaves top breathing room and does not fill viewport`() { + val layout = computeRecoveryMapPageLayout( + maxWidthDp = 360f, + maxHeightDp = 470f, + aspect = 0.55f, + ) + + assertEquals(12f, layout.topPaddingDp, 0.001f) + assertTrue(layout.canvasHeightDp < 430f) + assertTrue(layout.canvasWidthDp < 300f) + } +} diff --git a/app/src/test/java/com/ironlog/app/ui/state/WorkoutReducerRemoveExerciseTest.kt b/app/src/test/java/com/ironlog/app/ui/state/WorkoutReducerRemoveExerciseTest.kt new file mode 100644 index 0000000..6db175f --- /dev/null +++ b/app/src/test/java/com/ironlog/app/ui/state/WorkoutReducerRemoveExerciseTest.kt @@ -0,0 +1,50 @@ +package com.ironlog.app.ui.state + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WorkoutReducerRemoveExerciseTest { + + @Test + fun removingBaseExerciseTracksOriginalBaseIndexAndReindexesSessionMaps() { + val state = WorkoutState( + inputs = mapOf(0 to WorkoutInput("100", "8"), 1 to WorkoutInput("80", "10"), 2 to WorkoutInput("50", "12")), + setLog = mapOf( + 0 to listOf(LoggedSet(weight = 100.0, reps = 8.0)), + 1 to listOf(LoggedSet(weight = 80.0, reps = 10.0)), + 2 to listOf(LoggedSet(weight = 50.0, reps = 12.0)), + ), + supersetGroups = mapOf(0 to "A", 1 to "A", 2 to null), + ) + + val next = workoutReducer( + state, + WorkoutAction.RemoveExercise(exIndex = 1, baseExercisesCount = 3, removedBaseIndex = 4), + ) + + assertTrue(next.removedBaseExerciseIndices.contains(4)) + assertEquals(2, next.inputs.size) + assertEquals("50", next.inputs[1]?.weight) + assertEquals(1, next.setLog[1]?.size) + assertEquals(50.0, next.setLog[1]?.first()?.weight ?: 0.0, 0.0) + } + + @Test + fun removingAddedExerciseDoesNotMarkBaseExerciseRemoved() { + val state = WorkoutState( + addedExercises = listOf( + AddedExerciseEntry(exerciseId = "added-a", name = "Added A"), + AddedExerciseEntry(exerciseId = "added-b", name = "Added B"), + ), + ) + + val next = workoutReducer( + state, + WorkoutAction.RemoveExercise(exIndex = 3, baseExercisesCount = 2), + ) + + assertTrue(next.removedBaseExerciseIndices.isEmpty()) + assertEquals(listOf("Added A"), next.addedExercises.map { it.name }) + } +} diff --git a/app/src/test/java/com/ironlog/app/ui/viewmodel/GamificationBadgeUnlockTest.kt b/app/src/test/java/com/ironlog/app/ui/viewmodel/GamificationBadgeUnlockTest.kt new file mode 100644 index 0000000..aceddbd --- /dev/null +++ b/app/src/test/java/com/ironlog/app/ui/viewmodel/GamificationBadgeUnlockTest.kt @@ -0,0 +1,33 @@ +package com.ironlog.app.ui.viewmodel + +import com.ironlog.app.domain.gamification.IronGrade +import org.junit.Assert.assertEquals +import org.junit.Test + +class GamificationBadgeUnlockTest { + @Test + fun `grade progression unlocks canonical grade badges in order`() { + val badges = unlockedBadgesAfterGrade( + existingCsv = "", + currentGrade = IronGrade.STEEL, + ) + + assertEquals( + listOf("Graphite", "Iron", "Steel"), + badges, + ) + } + + @Test + fun `existing non grade badges are preserved while grade badges are upgraded`() { + val badges = unlockedBadgesAfterGrade( + existingCsv = "Graphite,founder", + currentGrade = IronGrade.TITANIUM, + ) + + assertEquals( + listOf("Graphite", "Iron", "Steel", "Titanium", "founder"), + badges, + ) + } +} diff --git a/app/src/test/java/com/ironlog/app/ui/viewmodel/GamificationXpReconciliationTest.kt b/app/src/test/java/com/ironlog/app/ui/viewmodel/GamificationXpReconciliationTest.kt new file mode 100644 index 0000000..d49e89a --- /dev/null +++ b/app/src/test/java/com/ironlog/app/ui/viewmodel/GamificationXpReconciliationTest.kt @@ -0,0 +1,26 @@ +package com.ironlog.app.ui.viewmodel + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GamificationXpReconciliationTest { + @Test + fun `refresh keeps bonus xp above canonical ledger floor`() { + val total = reconciledLedgerXp( + cachedTotalXp = 125L, + ledgerTotalXp = 100L, + ) + + assertEquals(125L, total) + } + + @Test + fun `refresh raises stale cached xp to canonical ledger floor`() { + val total = reconciledLedgerXp( + cachedTotalXp = 25L, + ledgerTotalXp = 100L, + ) + + assertEquals(100L, total) + } +} diff --git a/app/src/test/java/com/ironlog/app/widget/ForgeFoxWidgetPresentationTest.kt b/app/src/test/java/com/ironlog/app/widget/ForgeFoxWidgetPresentationTest.kt new file mode 100644 index 0000000..390551e --- /dev/null +++ b/app/src/test/java/com/ironlog/app/widget/ForgeFoxWidgetPresentationTest.kt @@ -0,0 +1,42 @@ +package com.ironlog.app.widget + +import com.ironlog.app.R +import org.junit.Assert.assertEquals +import org.junit.Test + +class ForgeFoxWidgetPresentationTest { + @Test + fun `layout resolver classifies canonical widget sizes`() { + assertEquals( + ForgeWidgetLayoutClass.SMALL, + resolveForgeWidgetLayoutClass(110f, 110f, ForgeWidgetLayoutClass.MEDIUM), + ) + assertEquals( + ForgeWidgetLayoutClass.MEDIUM, + resolveForgeWidgetLayoutClass(180f, 180f, ForgeWidgetLayoutClass.SMALL), + ) + assertEquals( + ForgeWidgetLayoutClass.TALL, + resolveForgeWidgetLayoutClass(220f, 320f, ForgeWidgetLayoutClass.MEDIUM), + ) + assertEquals( + ForgeWidgetLayoutClass.WIDE, + resolveForgeWidgetLayoutClass(320f, 160f, ForgeWidgetLayoutClass.MEDIUM), + ) + } + + @Test + fun `streak icon uses the single official drawable resource`() { + assertEquals(R.drawable.ic_forge_streak_dumbbell, ForgeFoxWidgetAssets.streakIcon) + } + + @Test + fun `presentation keeps short labels for constrained widgets`() { + val active = forgePresentationFor(WidgetVisualState.ACTIVE_STREAK, null) + val atRisk = forgePresentationFor(WidgetVisualState.AT_RISK, null) + + assertEquals("DAY STREAK", active.title) + assertEquals("AT RISK", atRisk.title) + assertEquals("Don't let it break!", atRisk.message) + } +} diff --git a/app/src/test/java/com/ironlog/app/widget/WidgetStateTest.kt b/app/src/test/java/com/ironlog/app/widget/WidgetStateTest.kt new file mode 100644 index 0000000..7cb35fa --- /dev/null +++ b/app/src/test/java/com/ironlog/app/widget/WidgetStateTest.kt @@ -0,0 +1,35 @@ +package com.ironlog.app.widget + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WidgetStateTest { + + @Test fun `default WidgetState has safe defaults`() { + val state = WidgetState() + assertEquals("Uncalibrated", state.grade) + assertEquals(1, state.level) + assertEquals(0, state.dailyStreakDays) + assertEquals(0, state.streakWeeks) + assertEquals("No Plan", state.recommendedDayName) + assertEquals("Choose a program", state.primaryActionLabel) + assertEquals("ProgramPicker", state.primaryActionRoute) + assertTrue(state.xpPercent in 0f..1f) + } + + @Test fun `xpPercent is clamped to 0-1`() { + val state = WidgetState(xpInLevel = 200L, xpForNextLevel = 100L) + assertEquals(1.0f, state.xpPercent, 0.001f) + } + + @Test fun `xpPercent is 0 when xpForNextLevel is 0`() { + val state = WidgetState(xpInLevel = 0L, xpForNextLevel = 0L) + assertEquals(0.0f, state.xpPercent, 0.001f) + } + + @Test fun `xpPercent computes correctly for partial level`() { + val state = WidgetState(xpInLevel = 50L, xpForNextLevel = 200L) + assertEquals(0.25f, state.xpPercent, 0.001f) + } +} diff --git a/app/src/test/java/com/ironlog/app/widget/WidgetVisualStateResolverTest.kt b/app/src/test/java/com/ironlog/app/widget/WidgetVisualStateResolverTest.kt new file mode 100644 index 0000000..0299f77 --- /dev/null +++ b/app/src/test/java/com/ironlog/app/widget/WidgetVisualStateResolverTest.kt @@ -0,0 +1,156 @@ +package com.ironlog.app.widget + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId + +class WidgetVisualStateResolverTest { + + @Test + fun `weekly completion is Monday through Sunday for current week`() { + val weekStart = LocalDate.of(2026, 6, 1) + + val completion = buildWeeklyCompletion( + workoutDates = setOf( + LocalDate.of(2026, 6, 1), + LocalDate.of(2026, 6, 3), + LocalDate.of(2026, 6, 7), + LocalDate.of(2026, 5, 31), + ), + weekStart = weekStart, + ) + + assertEquals(listOf(true, false, true, false, false, false, true), completion) + } + + @Test + fun `workout soon uses real reminder time and distinguishes early workouts`() { + val early = resolveWidgetVisualState( + WidgetVisualInputs( + streakDays = 5, + todayCompleted = false, + weekSessionsCount = 2, + weeklyGoal = 4, + minutesUntilWorkout = 25, + scheduledWorkoutHour = 6, + isAtRisk = false, + isRecoveryDay = false, + hasRecentNewPb = false, + returnedAfterGap = false, + ) + ) + val later = resolveWidgetVisualState( + WidgetVisualInputs( + streakDays = 5, + todayCompleted = false, + weekSessionsCount = 2, + weeklyGoal = 4, + minutesUntilWorkout = 25, + scheduledWorkoutHour = 18, + isAtRisk = false, + isRecoveryDay = false, + hasRecentNewPb = false, + returnedAfterGap = false, + ) + ) + + assertEquals(WidgetVisualState.EARLY_WORKOUT, early) + assertEquals(WidgetVisualState.WORKOUT_SOON, later) + } + + @Test + fun `completion states are prioritized from real workout outcomes`() { + val missionComplete = resolveWidgetVisualState( + WidgetVisualInputs( + streakDays = 12, + todayCompleted = true, + weekSessionsCount = 4, + weeklyGoal = 4, + minutesUntilWorkout = null, + scheduledWorkoutHour = null, + isAtRisk = false, + isRecoveryDay = false, + hasRecentNewPb = false, + returnedAfterGap = false, + ) + ) + val newPb = resolveWidgetVisualState( + WidgetVisualInputs( + streakDays = 12, + todayCompleted = true, + weekSessionsCount = 3, + weeklyGoal = 4, + minutesUntilWorkout = null, + scheduledWorkoutHour = null, + isAtRisk = false, + isRecoveryDay = false, + hasRecentNewPb = true, + returnedAfterGap = false, + ) + ) + val comeback = resolveWidgetVisualState( + WidgetVisualInputs( + streakDays = 1, + todayCompleted = true, + weekSessionsCount = 1, + weeklyGoal = 4, + minutesUntilWorkout = null, + scheduledWorkoutHour = null, + isAtRisk = false, + isRecoveryDay = false, + hasRecentNewPb = false, + returnedAfterGap = true, + ) + ) + + assertEquals(WidgetVisualState.MISSION_COMPLETE, missionComplete) + assertEquals(WidgetVisualState.NEW_PB, newPb) + assertEquals(WidgetVisualState.COMEBACK, comeback) + } + + @Test + fun `safe incomplete streak remains active instead of coach mode`() { + val state = resolveWidgetVisualState( + WidgetVisualInputs( + streakDays = 23, + todayCompleted = false, + weekSessionsCount = 2, + weeklyGoal = 4, + minutesUntilWorkout = null, + scheduledWorkoutHour = null, + isAtRisk = false, + isRecoveryDay = false, + hasRecentNewPb = false, + returnedAfterGap = false, + ) + ) + + assertEquals(WidgetVisualState.ACTIVE_STREAK, state) + } + + @Test + fun `recent pb hook expires instead of becoming a permanent fake state`() { + val zone = ZoneId.systemDefault() + val now = LocalDateTime.of(2026, 6, 6, 12, 0) + val nowMs = now.atZone(zone).toInstant().toEpochMilli() + + assertEquals( + true, + isRecentWidgetEvent( + eventEpochMs = now.minusHours(2).atZone(zone).toInstant().toEpochMilli(), + nowEpochMs = nowMs, + maxAgeHours = 24, + ) + ) + assertEquals( + false, + isRecentWidgetEvent( + eventEpochMs = now.minusDays(3).atZone(zone).toInstant().toEpochMilli(), + nowEpochMs = nowMs, + maxAgeHours = 24, + ) + ) + } +} diff --git a/assets/.gitkeep b/assets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/assets/fonts/.gitkeep b/assets/fonts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/assets/images/.gitkeep b/assets/images/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/assets/site/body_map_back.svg b/assets/site/body_map_back.svg deleted file mode 100644 index 51266f4..0000000 --- a/assets/site/body_map_back.svg +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/site/body_map_front.svg b/assets/site/body_map_front.svg deleted file mode 100644 index 70c9d21..0000000 --- a/assets/site/body_map_front.svg +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/site/chibi_determined.webp b/assets/site/chibi_determined.webp deleted file mode 100644 index 0a56080..0000000 Binary files a/assets/site/chibi_determined.webp and /dev/null differ diff --git a/assets/site/chibi_dumbbell.webp b/assets/site/chibi_dumbbell.webp deleted file mode 100644 index 5706477..0000000 Binary files a/assets/site/chibi_dumbbell.webp and /dev/null differ diff --git a/assets/site/chibi_proud.webp b/assets/site/chibi_proud.webp deleted file mode 100644 index 9df8007..0000000 Binary files a/assets/site/chibi_proud.webp and /dev/null differ diff --git a/assets/site/chibi_streak.webp b/assets/site/chibi_streak.webp deleted file mode 100644 index b7fa036..0000000 Binary files a/assets/site/chibi_streak.webp and /dev/null differ diff --git a/assets/site/chibi_watch.webp b/assets/site/chibi_watch.webp deleted file mode 100644 index 8438e6d..0000000 Binary files a/assets/site/chibi_watch.webp and /dev/null differ diff --git a/assets/site/forge_streak_dumbbell.png b/assets/site/forge_streak_dumbbell.png deleted file mode 100644 index 984f8a3..0000000 Binary files a/assets/site/forge_streak_dumbbell.png and /dev/null differ diff --git a/assets/site/ironlog_logo.svg b/assets/site/ironlog_logo.svg deleted file mode 100644 index 1ed7e37..0000000 --- a/assets/site/ironlog_logo.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..42673f4 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("com.android.application") version "8.13.2" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("org.jetbrains.kotlin.kapt") version "2.1.0" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false +} + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("io.objectbox:objectbox-gradle-plugin:5.4.2") + } +} diff --git a/docs/.gitkeep b/docs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/CODEBASE_REVIEW_2026-07-27.md b/docs/CODEBASE_REVIEW_2026-07-27.md new file mode 100644 index 0000000..d044634 --- /dev/null +++ b/docs/CODEBASE_REVIEW_2026-07-27.md @@ -0,0 +1,78 @@ +# IronLog codebase review — 2026-07-27 + +## Executive status + +IronLog is past the prototype stage. The native Android app builds, its JVM suite and lint gate pass, signed minified APK/AAB outputs have been produced, and the latest signed APK has been smoke-tested on both an API 36.1 emulator and a physical Android device. The main remaining work is release operations and broader device-level regression coverage, not a ground-up feature rewrite. + +## Where development left off + +The stabilization pass closed the highest-risk correctness work across persistence, workout lifecycle, import/export, recovery, widgets, QR sharing, Health Connect, and the body map: + +- ObjectBox startup no longer destroys a database after an initialization failure. +- Workout creation, completion, discard, history cascades, and import writes use transactions where cross-entity consistency matters. +- Imported workouts carry stable provenance so append/restore behavior does not collapse distinct sessions. +- Workout completion metrics are idempotent and a dismissed foreground notification no longer destroys resumable workout state. +- Readiness clocks, inactivity streak semantics, reminder keys, widget history ordering, bounded QR decoding, and partial Health Connect permissions were corrected. +- Body-map drawing and hit testing now share the same origin-centered transform and the full-screen map owns a constrained viewport below its controls. +- Automatic Android backup/device transfer is disabled for sensitive fitness/photo data; the explicit IronLog backup path remains available. +- Compile/target SDK, Android Gradle Plugin, Gradle, and ObjectBox were moved to the current project baseline documented in the root build files. + +## Verification evidence + +Current local gate: + +- 134 JVM tests, 0 failures, 0 errors. +- `lintDebug`: 0 errors. +- `assembleDebug`: APK produced successfully. +- Signed minified release APK and AAB previously built successfully with private local signing material. +- Fresh onboarding, starter-plan selection, Home, Recovery Map front/back views, muscle hit testing, and landscape recreation smoke-tested on an API 36.1 emulator. +- Stable signed release installed on the physical device with `adb install -r`, preserving app data. +- No actionable `TODO`, `FIXME`, `NotImplementedError`, or `error("TODO")` markers in compiled app source. + +## What should be removed from the public repository + +1. **Release secrets and machine configuration.** Never publish `local.properties`, `*.jks`, API keys, databases, user exports, photos, or device logs. The old local Git history contains a release keystore and local properties; publish through a sanitized branch that does not share that history. +2. **Generated output.** Keep `build/`, `app/build/`, Gradle caches, reports, APKs, AABs, and local validation artifacts out of source control. +3. **Historical quarantine source.** `app/src/main/quarantine_java` contains 108 noncompiled port-reference files. It is not a Gradle source set and should be omitted from the public source snapshot. If historical parity research is still useful locally, retain it outside the published tree. +4. **The stale landing-page implementation.** The existing public `main` branch describes ObjectBox and recovery work as planned/prototype work. Replace that site snapshot with the current Android repository and accurate README rather than maintaining two conflicting product states. + +## What should remain, but be edited carefully + +- Keep `docs/superpowers/` as historical engineering evidence, with old checklist documents clearly labeled superseded rather than presented as the live backlog. +- Keep ObjectBox's generated model files in source control and review model IDs/UIDs as migration-critical data whenever entities change. +- Keep the personal-use license from the existing public repository unless the owner explicitly chooses a different distribution model. +- Keep public screenshots sanitized and generated from a clean test profile. +- Keep release signing local; public CI should test, lint, and assemble only the unsigned/debug path. + +## What is left to do now + +### Release blockers + +- Rotate the Android release key/passwords before any Play Store or public binary distribution because prior local Git history tracked signing material. +- Confirm whether `com.ironlogpro.app` has ever been published. That determines whether a signing-key rotation is free or must follow Play App Signing recovery/upgrade procedures. +- Add Play Console/service-account delivery only after the signing decision; no Play Publisher configuration exists in this repository today. +- Run a physical-device regression matrix covering workout start/resume/complete/discard, process death, notification actions, camera/photos, QR import, Health Connect permission variants, widgets, backup/restore, and orientation. + +### High-value engineering work + +- Add Android instrumentation/Compose UI tests. The current automated suite is JVM-only, so navigation, permission UI, widgets, services, camera, and rendering still depend on manual/device QA. +- Add migration fixtures that open representative older ObjectBox databases, not only unit-level import fixtures. +- Add a deterministic end-to-end backup/restore test with photos and calibration/profile data on an emulator. +- Add crash and performance observability only with an explicit privacy decision; do not silently introduce cloud telemetry into a local-first product. + +### Product and polish + +- Complete accessibility review for TalkBack semantics, large font scales, touch targets, contrast across every selectable theme, and reduced motion. +- Verify Forge Fox widget sizing and cropping across common launcher grids and OEMs. +- Capture a sanitized, consistent screenshot set after the next physical-device regression pass. +- Reconcile public release notes and versioning once distribution is configured. + +## Recommended order + +1. Publish the sanitized source/README branch and let CI prove the public build path. +2. Complete the read-only independent source audit and fix only independently reproduced findings. +3. Run the physical-device regression matrix. +4. Resolve package publication history and rotate signing credentials. +5. Configure distribution, tag the first supported public build, and publish checksums/release notes. + +This document is the current operational backlog. Older implementation plans explain how features were built; they should not override this status unless a newer dated review explicitly replaces it. diff --git a/docs/PARITY_GAPS.md b/docs/PARITY_GAPS.md new file mode 100644 index 0000000..e5809b0 --- /dev/null +++ b/docs/PARITY_GAPS.md @@ -0,0 +1,246 @@ +# IronLog Android - Current RN Parity Gap Tracker + +Last reconciled: 2026-05-18 + +This file replaces the stale Gemini gap list from 2026-05-18. The old list had 24 gaps, but most were fixed in subsequent passes and documented in `Z:\KOTLIN\AGENTS.md`. + +Scope: active RN runtime features only, compared against `Z:\ironlog\src\navigation\AppNavigator.js` and Kotlin `Z:\KOTLIN\UnifiedPort\app\src\main\java\com\ironlog\app\navigation\AppNavigator.kt`. + +## Current Parity Snapshot + +| Area | Status | Notes | +| --- | --- | --- | +| Active route coverage | MATCH | Kotlin has all active RN stack routes and the same 5 tab roots. | +| Core tab workflows | MOSTLY MATCH | Home, Plans, Log, Stats, Settings are implemented; remaining issues are workflow details, not missing tabs. | +| Plan/program flows | MOSTLY MATCH | Browse, import, AI creation, duplicate, edit, start, and day editing exist. | +| Active workout | NEEDS DEVICE VERIFY | Core loop is rich, but background/lock/resume/timer/haptic/notification behavior must be device-proven. | +| Analytics/intelligence | MOSTLY MATCH | Most old metric gaps are fixed; Training Intelligence still has a context gap. | +| Body/photos/gym profiles | MOSTLY MATCH | Main flows exist; photo capture/import/compare/export need real-device proof. | +| Backup/import/export | MOSTLY MATCH | Core paths and crash fixes exist; document picker/share/restore edge cases need device proof. | +| Settings/notifications/haptics | NEEDS DEVICE VERIFY | UI exists, but notification and locked-screen haptic behavior are device-sensitive. | + +Estimated static parity: 94-96%. +Estimated production-proven parity: 88-92% until device walkthrough evidence is captured. + +## Open Gaps + +### OPEN-01 - TrainingIntelligenceScreen start workout loses recommended day context + +Status: CLOSED IN CODE / NEEDS DEVICE VERIFY + +Files: +- `app/src/main/java/com/ironlog/app/ui/screens/TrainingIntelligenceScreen.kt` +- `app/src/main/java/com/ironlog/app/navigation/AppNavigator.kt` + +Resolution: +- `TrainingIntelligenceScreen` now derives a recommended active-plan day from history. +- The quick action passes the recommended `dayId` to `AppNavigator.kt`. +- Navigation now opens `ActiveWorkout/{dayId}` when context exists, with blank workout only as fallback. + +Expected RN parity: +- The quick action should start the recommended next session when a recommendation exists. +- It should only fall back to blank workout when no plan/day context is available. + +Implementation target: +- Change the screen callback to carry an optional `dayId`. +- Derive the recommended day from the same data used by the Training Intelligence directive. +- Navigate to `ActiveWorkout/{dayId}` when present. + +Verification: +- From Training Intelligence, tap Start Workout with an active program. +- Active Workout should open pre-populated with the recommended day exercises. + +### OPEN-02 - WorkoutCalendar start workout is still generic + +Status: CLOSED IN CODE / NEEDS DEVICE VERIFY + +Files: +- `app/src/main/java/com/ironlog/app/ui/screens/WorkoutCalendarScreen.kt` +- `app/src/main/java/com/ironlog/app/navigation/AppNavigator.kt` + +Resolution: +- Calendar long-press start now stores `active_workout_intended_date` before opening `ActiveWorkout`. +- `WorkoutRepository` consumes that date when creating the active workout and uses the selected past/today date as `startedAt`. +- Future-date long-press starts are blocked to avoid future-started active workout duration bugs. + +Expected RN parity: +- If RN only starts a blank workout from calendar, this is acceptable. +- If RN preserves selected date or uses the active plan's next session for that date, Kotlin needs to pass that context. + +Implementation target: +- Audit RN calendar behavior directly before changing this. +- If date context matters, add an active workout start argument or persisted intended workout date. + +Verification: +- Long-press an empty past/future date. +- Confirm whether the resulting workout date and history entry match expected RN behavior. + +### OPEN-03 - History edit date/time still uses raw text fields + +Status: CLOSED IN CODE / NEEDS DEVICE VERIFY + +Files: +- `app/src/main/java/com/ironlog/app/ui/screens/HistoryScreen.kt` + +Resolution: +- History edit now has Material date and time pickers. +- Raw fields remain editable, but invalid date/time now shows an inline error. +- Saving no longer silently falls back to the old timestamp when input is invalid. + +Expected RN parity: +- Editing should be deliberate and validated. +- A picker-style date/time edit is safer than raw fields. + +Implementation target: +- Replace or supplement raw date/time fields with Material date/time picker controls. +- Show inline validation when parsing fails. +- Do not silently save the old timestamp when the user entered invalid text. + +Verification: +- Edit a log date/time. +- Try invalid input and confirm visible error. +- Save valid input and verify persistence after app restart. + +### OPEN-04 - Onboarding browse-program navigation uses pending route token + +Status: CLOSED IN CODE / NEEDS DEVICE VERIFY + +Files: +- `app/src/main/java/com/ironlog/app/ui/screens/OnboardingScreen.kt` +- `app/src/main/java/com/ironlog/app/navigation/AppNavigator.kt` +- `app/src/main/java/com/ironlog/app/MainActivity.kt` + +Resolution: +- Onboarding entry now clears any stale `pending_nav_route` before rendering. +- Browse Programs can still set the one-shot ProgramPicker token for the live transition, but old interrupted tokens are removed. + +Expected RN parity: +- Browse Programs should directly and reliably complete onboarding then open ProgramPicker. + +Implementation target: +- Prefer a scoped navigation event or direct post-navigation callback. +- At minimum, clear stale pending route when onboarding starts/resumes. + +Verification: +- Reset onboarding. +- Tap Browse Programs. +- Back out, restart app, and ensure no stale ProgramPicker navigation fires later. + +## Device Verification Gaps + +These are not proven missing in code, but cannot be honestly closed without a real device walkthrough. + +### DEVICE-01 - Active workout background/lock/resume survival + +Status: CODE HARDENED / NEEDS DEVICE VERIFY + +Code hardening completed 2026-05-18: +- `ActiveWorkoutScreen` now writes a lifecycle-critical active-workout draft snapshot synchronously on `ON_STOP`. +- The disposal path now also uses the blocking draft snapshot instead of launching a coroutine that may be cancelled during teardown. +- Normal typing/logging persistence still uses the existing async path, so steady-state UI interaction remains lightweight. + +Evidence needed: +- Start a workout from Home and Plans. +- Log multiple sets. +- Minimize, lock screen, wait, unlock, resume. +- Confirm set inputs, order, rest timer, and active workout notification survive. + +Risk: +- This area has had recent real-device failures, so it remains a hard verification item. + +### DEVICE-02 - Foreground workout notification and notification permission flow + +Status: CODE HARDENED / NEEDS DEVICE VERIFY + +Code hardening completed 2026-05-18: +- `WorkoutForegroundService` is now the single source for active workout notifications. +- The live notification is theme-tinted from `ironlog_settings`. +- The notification shows current workout, set label, rest seconds, and session time. +- Resting notifications expose `Skip` and `+30s` actions. +- All live workout notifications expose `Finish`. +- Notification actions deep-link through `MainActivity` and set `pending_workout_action`, so `ActiveWorkoutScreen` applies the real reducer path instead of leaving dead background actions. + +Evidence needed: +- Permission denied path. +- Permission granted path. +- Test notification from Settings. +- Active workout persistent notification with current workout/set/timer. +- Notification tap/action deep-links back to correct workout state. + +### DEVICE-03 - Locked-screen timer haptics + +Evidence needed: +- Start rest timer. +- Lock phone during final countdown. +- Confirm final 5-second haptic ramp fires while app is backgrounded/locked. + +### DEVICE-04 - Progress photo lifecycle + +Evidence needed: +- Capture photo. +- Import from gallery. +- Open viewer. +- Compare two photos. +- Delete/export/clear behavior if exposed. + +### DEVICE-05 - Backup/import/export real intents + +Evidence needed: +- Full backup share/save. +- Restore preview. +- Replace/append behavior. +- CSV/OpenWeight/data portability import and export. +- Malformed file rejection. + +## Closed Gaps From Old `PARITY_GAPS.md` + +These were listed as open in the old tracker but are now closed or confirmed false positive. + +| Old Gap | Current Status | Evidence | +| --- | --- | --- | +| GAP-01 ProgramInsights wrong adherence formula | CLOSED | `AGENTS.md` 2026-05-18 ProgramInsights rewrite. | +| GAP-02 ProgramInsights wrong active plan | CLOSED | Active plan fallback implemented. | +| GAP-03 Deleting active gym profile stale ID | CLOSED | `GymProfilesScreen.kt` clears or reassigns `active_gym_profile_id`. | +| GAP-04 VolumeAnalytics PROGRAM window | CLOSED | Program-duration-aware window implemented. | +| GAP-05 Empty calendar day dead tap | CLOSED | `AddWorkoutForDateSheet` exists and is wired. | +| GAP-06 Superset grouping/auto-advance | CLOSED | Superset labels, grouping behavior, and scroll-to-partner logic exist in ActiveWorkout. | +| GAP-07 Volume unit conversion | CLOSED | VolumeAnalytics converts display values by weight unit. | +| GAP-08 Progress photo labels | CLOSED | Labels swapped/fixed. | +| GAP-09 BodyWeight delete confirmation | CLOSED | Delete confirmation dialog implemented. | +| GAP-10 Duplicate plan copies day IDs | CLOSED | Duplicate uses `importFullPlan`, creating fresh entities/IDs. | +| GAP-11 ExerciseProgress consistency always 100% | CLOSED | ISO week sessions-per-week chart implemented. | +| GAP-12 TrainingIntelligence blank start | OPEN | See OPEN-01. | +| GAP-13 Onboarding unit selector | CLOSED | kg/lbs toggle added and persisted. | +| GAP-14 BodyMeasurements expanded trend | CLOSED | Expanded trend dialog/range chips implemented. | +| GAP-15 History date picker | OPEN | See OPEN-03. | +| GAP-16 History star rating widget | CLOSED | 5-star selector implemented. | +| GAP-17 ProgramInsights stub cards | CLOSED | Added adherence, consistency, per-day breakdown, top PRs. | +| GAP-18 Calendar volume unit conversion | CLOSED | `toDisplayVolume(..., weightUnit)` used. | +| GAP-19 Calendar Monday week start | CLOSED | ISO Monday start implemented. | +| GAP-20 Gym plate color picker | FALSE POSITIVE | Inline preset color picker already exists. | +| GAP-21 ExerciseProgress reactive weight unit | CLOSED | `AppNavigator.kt` passes `statsState.weightUnit`; screen recomputes with `weightUnit`. | +| GAP-22 BodyWeight validation unit-aware | CLOSED | kg/lbs validation ranges implemented. | +| GAP-23 Home XP progress bar | CLOSED | Tier progress bar implemented. | +| GAP-24 Onboarding pending route | OPEN | See OPEN-04. | + +## Kotlin Features That Exceed RN Parity + +These should not be removed just to chase RN sameness. + +- Cloud AI plan generation with system-prompt JSON enforcement and larger token budget. +- AI import review with deterministic fuzzy matching and one-tap suggested mapping. +- Smart plan structuring for warmups, supersets, rest normalization, and metadata repair. +- Exercise tracking-type normalization and startup repair. +- Process-scoped Cloud AI insight cache. +- Material You theme system and multiple visual themes. +- Duplicate-safe active workout row binding for repeated exercise IDs. + +## Next Closure Pass + +Recommended order: + +1. Run a real-device proof pass for DEVICE-01 through DEVICE-05. +2. Verify the four code-closed gaps above on device. +3. If device behavior matches RN, mark the code-closed items fully closed. + +Only after those are done should parity be marked 100%. diff --git a/docs/business/.gitkeep b/docs/business/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/changes/.gitkeep b/docs/changes/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/ironlog-2.0/.gitkeep b/docs/ironlog-2.0/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/releases/.gitkeep b/docs/releases/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/superpowers/plans/2026-05-18-fatigue-suggestion.md b/docs/superpowers/plans/2026-05-18-fatigue-suggestion.md new file mode 100644 index 0000000..ea3bba9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-fatigue-suggestion.md @@ -0,0 +1,438 @@ +# Fatigue-Based Workout Suggestion Implementation Plan + +> **Status:** Historical execution plan. Its checkboxes were not backfilled and are not a current backlog. Use `AGENTS.md` and the current source/tests as the authoritative project state. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Surface the best plan day to train today based on muscle-group recovery readiness, with the recommended day card floating to the top of the HomeScreen list and glowing with an animated accent border. + +**Architecture:** A new `WorkoutSuggestionEngine` maps each exercise name to a muscle region using keyword matching, then scores each plan day against the region-level readiness scores produced by the existing `RecoveryReadinessEngine`. `HomeScreen` reorders its day card list and applies an animated color border to the recommended card. + +**Tech Stack:** Kotlin, Jetpack Compose `animateColorAsState`, existing `RecoveryReadinessEngine` (no new dependencies) + +--- + +## File Map + +| Action | File | +|--------|------| +| Create | `app/src/main/java/com/ironlog/app/domain/intelligence/WorkoutSuggestionEngine.kt` | +| Create | `app/src/test/java/com/ironlog/app/domain/intelligence/WorkoutSuggestionEngineTest.kt` | +| Modify | `app/src/main/java/com/ironlog/app/ui/screens/HomeScreen.kt` — inject engine, reorder cards, add glow | + +--- + +### Task 1: WorkoutSuggestionEngine — keyword mapping and day scoring + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/domain/intelligence/WorkoutSuggestionEngine.kt` +- Test: `app/src/test/java/com/ironlog/app/domain/intelligence/WorkoutSuggestionEngineTest.kt` + +- [ ] **Step 1: Write the failing tests** + +Create `app/src/test/java/com/ironlog/app/domain/intelligence/WorkoutSuggestionEngineTest.kt`: + +```kotlin +package com.ironlog.app.domain.intelligence + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WorkoutSuggestionEngineTest { + + private val engine = WorkoutSuggestionEngine() + + // ── regionForExercise ────────────────────────────────────────────────── + + @Test fun `bench press maps to Push`() { + assertEquals("Push", engine.regionForExercise("Bench Press")) + } + + @Test fun `barbell row maps to Pull`() { + assertEquals("Pull", engine.regionForExercise("Barbell Row")) + } + + @Test fun `squat maps to Legs`() { + assertEquals("Legs", engine.regionForExercise("Back Squat")) + } + + @Test fun `plank maps to Core`() { + assertEquals("Core", engine.regionForExercise("Plank")) + } + + @Test fun `curl maps to Arms`() { + assertEquals("Arms", engine.regionForExercise("Dumbbell Curl")) + } + + @Test fun `lateral raise maps to Shoulders`() { + assertEquals("Shoulders", engine.regionForExercise("Lateral Raise")) + } + + @Test fun `unknown exercise maps to null`() { + assertEquals(null, engine.regionForExercise("Juggling")) + } + + // ── scoreDay ────────────────────────────────────────────────────────── + + @Test fun `day with fully rested regions scores near 1`() { + val readiness = mapOf("Push" to 1.0, "Pull" to 1.0, "Legs" to 1.0) + val score = engine.scoreDay(readiness, listOf("Bench Press", "Pull Up", "Squat")) + assertTrue("score should be >= 0.9 but was $score", score >= 0.9) + } + + @Test fun `day with all fatigued regions scores near 0`() { + val readiness = mapOf("Push" to 0.0, "Pull" to 0.0, "Legs" to 0.0) + val score = engine.scoreDay(readiness, listOf("Bench Press", "Pull Up", "Squat")) + assertTrue("score should be <= 0.2 but was $score", score <= 0.2) + } + + @Test fun `day with no matched exercises scores 0_5 (neutral)`() { + val readiness = mapOf("Push" to 1.0) + val score = engine.scoreDay(readiness, listOf("Juggling", "Yoga Flow")) + assertEquals(0.5, score, 0.001) + } + + // ── suggestDayIndex ──────────────────────────────────────────────────── + + @Test fun `returns index of highest-scoring day`() { + val readiness = mapOf("Push" to 1.0, "Legs" to 0.1) + val dayExerciseNames = listOf( + listOf("Squat", "Leg Press"), // day 0 — Legs fatigued → low score + listOf("Bench Press", "Dip"), // day 1 — Push fresh → high score + ) + val result = engine.suggestDayIndex(readiness, dayExerciseNames) + assertEquals(1, result) + } + + @Test fun `returns 0 when all days equally ready`() { + val readiness = mapOf("Push" to 1.0, "Pull" to 1.0) + val dayExerciseNames = listOf( + listOf("Bench Press"), + listOf("Pull Up"), + ) + val result = engine.suggestDayIndex(readiness, dayExerciseNames) + // Either is valid; ensure it returns a valid index + assertTrue(result in 0..1) + } + + @Test fun `returns 0 for empty day list`() { + assertEquals(0, engine.suggestDayIndex(emptyMap(), emptyList())) + } + + // ── recommendationBlurb ─────────────────────────────────────────────── + + @Test fun `blurb mentions the day name`() { + val readiness = mapOf("Push" to 0.9, "Legs" to 0.3) + val blurb = engine.recommendationBlurb(readiness, "Push Day") + assertTrue(blurb.contains("Push Day", ignoreCase = true)) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +cd Z:\KOTLIN\UnifiedPort +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.domain.intelligence.WorkoutSuggestionEngineTest" --info 2>&1 | Select-String -Pattern "FAIL|error|not found" | Select-Object -First 20 +``` + +Expected: compilation error — `WorkoutSuggestionEngine` does not exist yet. + +- [ ] **Step 3: Implement WorkoutSuggestionEngine** + +Create `app/src/main/java/com/ironlog/app/domain/intelligence/WorkoutSuggestionEngine.kt`: + +```kotlin +package com.ironlog.app.domain.intelligence + +/** + * Maps plan days to muscle-group readiness scores and recommends the best + * day to train based on recovery state from [RecoveryReadinessEngine]. + */ +class WorkoutSuggestionEngine { + + // keyword → region mapping (lowercase keywords for case-insensitive matching) + private val regionKeywords: Map> = mapOf( + "Push" to listOf("bench", "press", "dip", "push", "fly", "flye", "chest", "tricep"), + "Pull" to listOf("row", "pull", "lat", "back", "deadlift", "shrug", "bicep", "chin"), + "Legs" to listOf("squat", "leg", "lunge", "calf", "glute", "hip thrust", "rdl", "hamstring", "quad"), + "Core" to listOf("plank", "crunch", "ab", "oblique", "core", "sit-up", "situp", "hollow"), + "Arms" to listOf("curl", "extension", "forearm", "wrist"), + "Shoulders" to listOf("lateral raise", "shoulder", "overhead", "ohp", "front raise", "face pull", "upright row"), + ) + + /** + * Returns the training region for [exerciseName] using keyword matching, + * or null if no region matches. + */ + fun regionForExercise(exerciseName: String): String? { + val lower = exerciseName.lowercase() + return regionKeywords.entries.firstOrNull { (_, keywords) -> + keywords.any { kw -> lower.contains(kw) } + }?.key + } + + /** + * Scores a plan day against [readiness] (0.0 = fully fatigued, 1.0 = fully recovered). + * Returns 0.5 (neutral) if none of the exercises map to a known region. + */ + fun scoreDay(readiness: Map, exerciseNames: List): Double { + val scores = exerciseNames + .mapNotNull { name -> regionForExercise(name)?.let { region -> readiness[region] } } + return if (scores.isEmpty()) 0.5 else scores.average() + } + + /** + * Returns the 0-based index of the plan day with the highest readiness score. + * Ties are broken by lower index (stable). + */ + fun suggestDayIndex( + readiness: Map, + dayExerciseNames: List>, + ): Int { + if (dayExerciseNames.isEmpty()) return 0 + return dayExerciseNames + .mapIndexed { i, exercises -> i to scoreDay(readiness, exercises) } + .maxByOrNull { (_, score) -> score } + ?.first ?: 0 + } + + /** + * Returns a short human-readable recommendation sentence for the UI. + */ + fun recommendationBlurb(readiness: Map, dayName: String): String { + val topRegion = readiness.maxByOrNull { it.value } + val freshness = when { + (topRegion?.value ?: 0.5) >= 0.8 -> "Your muscles are fresh" + (topRegion?.value ?: 0.5) >= 0.5 -> "You're recovering well" + else -> "Take it easy today" + } + return "$freshness — $dayName looks like your best pick." + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +``` +cd Z:\KOTLIN\UnifiedPort +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.domain.intelligence.WorkoutSuggestionEngineTest" +``` + +Expected: `BUILD SUCCESSFUL`, all tests pass. + +- [ ] **Step 5: Commit** + +``` +git add app/src/main/java/com/ironlog/app/domain/intelligence/WorkoutSuggestionEngine.kt +git add app/src/test/java/com/ironlog/app/domain/intelligence/WorkoutSuggestionEngineTest.kt +git commit -m "feat: add WorkoutSuggestionEngine for fatigue-based day scoring" +``` + +--- + +### Task 2: HomeScreen — reorder cards + glow animation for recommended day + +**Files:** +- Modify: `app/src/main/java/com/ironlog/app/ui/screens/HomeScreen.kt` + +> **Context:** `HomeScreen` already renders a list of plan day cards. The `AppDataState` contains `plans` (List) and `settings` which hold `weeklyGoalDays`. The VM already calls `RecoveryReadinessEngine` for debrief — we reuse that same instance. + +- [ ] **Step 1: Read the top of HomeScreen to find the day-card rendering site** + +Open `app/src/main/java/com/ironlog/app/ui/screens/HomeScreen.kt` and locate: +1. Where `UiPlan.days` (or equivalent) is iterated to render day cards. +2. Any existing import of `RecoveryReadinessEngine`. + +Record the exact composable name that renders one day card (e.g., `PlanDayCard`, `DayCard`). + +- [ ] **Step 2: Add imports and engine instantiation at the HomeScreen composable call site** + +In the HomeScreen composable function, add (near the top of the function body, after `val state = ...`): + +```kotlin +// Fatigue suggestion — compute once per recomposition (cheap: pure math on remembered data) +val suggestionEngine = remember { WorkoutSuggestionEngine() } +val recoveryEngine = remember { RecoveryReadinessEngine() } + +val readiness: Map = remember(state.history) { + recoveryEngine.readinessByRegion(state.history, emptyMap()) +} + +val activePlan: UiPlan? = state.plans.firstOrNull { it.isActive } + +val orderedDays: List = remember(activePlan, readiness) { + val days = activePlan?.days ?: emptyList() + if (days.isEmpty()) return@remember days + val dayExerciseNames = days.map { day -> day.exercises.map { it.name } } + val bestIdx = suggestionEngine.suggestDayIndex(readiness, dayExerciseNames) + // Float the recommended day to position 0, preserving original order for the rest + val recommended = days[bestIdx] + listOf(recommended) + days.filterIndexed { i, _ -> i != bestIdx } +} + +val recommendedDayId: String? = orderedDays.firstOrNull()?.id +``` + +Add the import at the top of the file: +```kotlin +import com.ironlog.app.domain.intelligence.RecoveryReadinessEngine +import com.ironlog.app.domain.intelligence.WorkoutSuggestionEngine +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.RepeatMode +``` + +- [ ] **Step 3: Pass orderedDays to the day-card list and add the glow border** + +In the `LazyRow` (or `LazyColumn`) that iterates days, replace the existing `activePlan?.days` (or equivalent) iteration with `orderedDays`. Then, in the day card composable call, add a modifier for the glow: + +```kotlin +// Inside the loop that renders each day card: +val isRecommended = day.id == recommendedDayId + +// Pulsing glow using infinite transition +val infiniteTransition = rememberInfiniteTransition(label = "glow_${day.id}") +val glowAlpha by infiniteTransition.animateFloat( + initialValue = 0.55f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900), + repeatMode = RepeatMode.Reverse, + ), + label = "glowAlpha_${day.id}", +) + +val borderColor by animateColorAsState( + targetValue = if (isRecommended) + MaterialTheme.colorScheme.primary.copy(alpha = glowAlpha) + else + Color.Transparent, + animationSpec = tween(400), + label = "borderColor_${day.id}", +) + +// Wrap the existing day-card composable with a border modifier: +Box( + modifier = Modifier + .border( + width = if (isRecommended) 2.dp else 0.dp, + color = borderColor, + shape = RoundedCornerShape(16.dp), // match the card's corner radius + ) +) { + // existing day card composable here, e.g.: + // PlanDayCard(day = day, ...) + + if (isRecommended) { + // "Recommended" badge + Surface( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(bottomStart = 8.dp, topEnd = 16.dp), + modifier = Modifier.align(Alignment.TopEnd), + ) { + Text( + text = "⚡ Best Today", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } +} +``` + +Add missing imports: +```kotlin +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +``` + +- [ ] **Step 4: Build to verify no compilation errors** + +``` +cd Z:\KOTLIN\UnifiedPort +.\gradlew :app:assembleDebug 2>&1 | Select-String -Pattern "error:|BUILD" | Select-Object -Last 20 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 5: Run unit tests (full suite)** + +``` +.\gradlew :app:testDebugUnitTest +``` + +Expected: `BUILD SUCCESSFUL`, no regressions. + +- [ ] **Step 6: Commit** + +``` +git add app/src/main/java/com/ironlog/app/ui/screens/HomeScreen.kt +git commit -m "feat: float recommended workout day card to top with pulsing glow" +``` + +--- + +### Task 3: Recommendation blurb on the recommended card + +**Files:** +- Modify: `app/src/main/java/com/ironlog/app/ui/screens/HomeScreen.kt` + +- [ ] **Step 1: Add blurb text below the "Best Today" badge** + +Inside the `if (isRecommended)` block added in Task 2, below the badge `Surface`, add: + +```kotlin +val blurb = remember(readiness, day.name) { + suggestionEngine.recommendationBlurb(readiness, day.name) +} + +Text( + text = blurb, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 12.dp, bottom = 8.dp, end = 8.dp), +) +``` + +- [ ] **Step 2: Build and verify** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String -Pattern "error:|BUILD" | Select-Object -Last 5 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Commit** + +``` +git add app/src/main/java/com/ironlog/app/ui/screens/HomeScreen.kt +git commit -m "feat: show recovery recommendation blurb on best-today workout card" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ `WorkoutSuggestionEngine` scores days against readiness map +- ✅ Recommended day floats to position 0 +- ✅ Animated pulsing glow border on recommended card +- ✅ "Best Today" badge chip +- ✅ Recommendation blurb text +- ✅ No new dependencies required +- ✅ TDD: tests written before implementation + +**Placeholder scan:** No TBD/TODO in code blocks. All method signatures consistent across tasks. + +**Type consistency:** `WorkoutSuggestionEngine` uses `Map` readiness throughout — matches `RecoveryReadinessEngine.readinessByRegion()` return type. `UiPlanDay.id: String` used as key for recommended card check — matches `UiModels.kt`. diff --git a/docs/superpowers/plans/2026-05-18-health-connect.md b/docs/superpowers/plans/2026-05-18-health-connect.md new file mode 100644 index 0000000..fd0cf2f --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-health-connect.md @@ -0,0 +1,796 @@ +# Health Connect Integration Implementation Plan + +> **Status:** Historical execution plan. Its checkboxes were not backfilled and are not a current backlog. Use `AGENTS.md` and the current source/tests as the authoritative project state. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Write completed workout sessions to Android Health Connect and read back sleep, resting heart rate, and HRV data to enrich the recovery readiness score shown on HomeScreen. + +**Architecture:** A `HealthConnectRepository` wraps the Health Connect client with write (ExerciseSession, Weight) and read (Sleep, RHR, HRV) operations. A one-time `HealthConnectPermissionSheet` bottom-sheet requests permissions on first use. The existing `RecoveryReadinessEngine` gains a `blendWithBiometric()` extension function that combines Health Connect biometric data with the training-load readiness score. No new ViewModel is needed — the existing `WatermelonAppDataViewModel` gains a `healthConnect` property. + +**Tech Stack:** Kotlin, `androidx.health.connect:connect-client:1.1.0-alpha07`, Jetpack Compose, existing `RecoveryReadinessEngine`, existing `HistoryEntry` model + +--- + +## File Map + +| Action | File | +|--------|------| +| Modify | `app/build.gradle.kts` — add Health Connect dependency | +| Create | `app/src/main/java/com/ironlog/app/data/health/HealthConnectRepository.kt` | +| Create | `app/src/main/java/com/ironlog/app/data/health/BiometricSnapshot.kt` | +| Create | `app/src/main/java/com/ironlog/app/ui/screens/HealthConnectPermissionSheet.kt` | +| Create | `app/src/test/java/com/ironlog/app/data/health/HealthConnectRepositoryTest.kt` | +| Modify | `app/src/main/java/com/ironlog/app/domain/intelligence/RecoveryReadinessEngine.kt` — add `blendWithBiometric()` | +| Modify | `app/src/main/java/com/ironlog/app/ui/screens/HomeScreen.kt` — trigger permission sheet + show blended score | +| Modify | `app/src/main/AndroidManifest.xml` — Health Connect activity + queries | + +--- + +### Task 1: Add Health Connect dependency + +**Files:** +- Modify: `app/build.gradle.kts` + +- [ ] **Step 1: Add dependency** + +Inside the `dependencies { }` block in `app/build.gradle.kts`, add: + +```kotlin + // Health Connect (replaces deprecated Google Fit) + implementation("androidx.health.connect:connect-client:1.1.0-alpha07") +``` + +- [ ] **Step 2: Sync and build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD|Download" | Select-Object -Last 15 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Commit** + +``` +git add app/build.gradle.kts +git commit -m "build: add Health Connect client dependency" +``` + +--- + +### Task 2: AndroidManifest — Health Connect integration metadata + +**Files:** +- Modify: `app/src/main/AndroidManifest.xml` + +- [ ] **Step 1: Read AndroidManifest.xml to find the application tag** + +Open `app/src/main/AndroidManifest.xml` and locate ``. + +- [ ] **Step 2: Add Health Connect queries and activity** + +Inside ``, before ``, add: + +```xml + + + + + + + +``` + +Inside ``, add the Health Connect permission rationale activity: + +```xml + + + + + + +``` + +Also add the privacy policy metadata (required by Health Connect): + +```xml + + +``` + +- [ ] **Step 3: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Commit** + +``` +git add app/src/main/AndroidManifest.xml +git commit -m "feat: add Health Connect manifest entries (queries, rationale activity, metadata)" +``` + +--- + +### Task 3: BiometricSnapshot data class + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/data/health/BiometricSnapshot.kt` + +- [ ] **Step 1: Create BiometricSnapshot** + +```kotlin +// app/src/main/java/com/ironlog/app/data/health/BiometricSnapshot.kt +package com.ironlog.app.data.health + +/** + * Latest biometric data read from Health Connect. + * All values are nullable — null means no recent data available. + * + * @param sleepHours Total sleep hours from last night (Health Connect SleepSessionRecord). + * @param restingHrBpm Resting heart rate in bpm (Health Connect RestingHeartRateRecord). + * @param hrvRmssd Heart Rate Variability RMSSD in ms (Health Connect HRVRecord). + * @param weightKg Latest body weight in kg (Health Connect WeightRecord). + */ +data class BiometricSnapshot( + val sleepHours: Double? = null, + val restingHrBpm: Long? = null, + val hrvRmssd: Double? = null, + val weightKg: Double? = null, +) +``` + +- [ ] **Step 2: Commit** + +``` +git add app/src/main/java/com/ironlog/app/data/health/BiometricSnapshot.kt +git commit -m "feat: add BiometricSnapshot data class for Health Connect readings" +``` + +--- + +### Task 4: HealthConnectRepository — write sessions, read biometrics + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/data/health/HealthConnectRepository.kt` +- Test: `app/src/test/java/com/ironlog/app/data/health/HealthConnectRepositoryTest.kt` + +- [ ] **Step 1: Write failing tests (unit-testable logic only)** + +> Note: The Health Connect client itself requires an Android runtime and cannot be unit-tested directly. We test the pure-logic helpers: permission set construction, biometric score computation, and `blendWithBiometric`. + +```kotlin +// app/src/test/java/com/ironlog/app/data/health/HealthConnectRepositoryTest.kt +package com.ironlog.app.data.health + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class HealthConnectRepositoryTest { + + // ── Biometric readiness score ───────────────────────────────────────── + + @Test fun `sleepScore returns 1_0 for 8+ hours`() { + assertEquals(1.0, sleepScore(8.0), 0.001) + assertEquals(1.0, sleepScore(9.0), 0.001) + } + + @Test fun `sleepScore returns 0_0 for 4 hours or less`() { + assertEquals(0.0, sleepScore(4.0), 0.001) + assertEquals(0.0, sleepScore(3.0), 0.001) + } + + @Test fun `sleepScore scales linearly between 4 and 8 hours`() { + val score6h = sleepScore(6.0) + assertTrue("6h sleep score should be ~0.5 but was $score6h", score6h in 0.45..0.55) + } + + @Test fun `hrvScore returns 1_0 for HRV >= 80 ms`() { + assertEquals(1.0, hrvScore(80.0), 0.001) + assertEquals(1.0, hrvScore(100.0), 0.001) + } + + @Test fun `hrvScore returns 0_0 for HRV <= 20 ms`() { + assertEquals(0.0, hrvScore(20.0), 0.001) + assertEquals(0.0, hrvScore(10.0), 0.001) + } + + @Test fun `biometricReadinessScore blends sleep and HRV`() { + val snap = BiometricSnapshot(sleepHours = 8.0, hrvRmssd = 80.0) + val score = biometricReadinessScore(snap) + assertTrue("Score with full sleep+HRV should be >= 0.9", score >= 0.9) + } + + @Test fun `biometricReadinessScore with null data returns 0_5 neutral`() { + val snap = BiometricSnapshot() // all nulls + val score = biometricReadinessScore(snap) + assertEquals(0.5, score, 0.001) + } + + @Test fun `biometricReadinessScore with only sleep data uses sleep score`() { + val snap = BiometricSnapshot(sleepHours = 4.0) + val score = biometricReadinessScore(snap) + assertTrue("Poor sleep only should score < 0.3", score < 0.3) + } +} + +// ── Pure helper functions (extracted for testability) ─────────────────────── + +fun sleepScore(hours: Double): Double = ((hours - 4.0) / 4.0).coerceIn(0.0, 1.0) + +fun hrvScore(rmssd: Double): Double = ((rmssd - 20.0) / 60.0).coerceIn(0.0, 1.0) + +fun biometricReadinessScore(snap: BiometricSnapshot): Double { + val scores = buildList { + snap.sleepHours?.let { add(sleepScore(it) * 0.6) } + snap.hrvRmssd?.let { add(hrvScore(it) * 0.4) } + } + if (scores.isEmpty()) return 0.5 + // Normalize: sum of weights of present components + val weights = buildList { + if (snap.sleepHours != null) add(0.6) + if (snap.hrvRmssd != null) add(0.4) + } + return (scores.sum() / weights.sum()).coerceIn(0.0, 1.0) +} +``` + +- [ ] **Step 2: Run tests** + +``` +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.data.health.HealthConnectRepositoryTest" 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 5 +``` + +Expected: compilation error — functions not found yet (they're defined in the test file itself for now; this verifies the test logic compiles). + +Actually since the helper functions are defined in the test file directly, the tests should compile. Run them: + +``` +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.data.health.HealthConnectRepositoryTest" +``` + +Expected: `BUILD SUCCESSFUL`, all tests pass. + +- [ ] **Step 3: Implement HealthConnectRepository** + +```kotlin +// app/src/main/java/com/ironlog/app/data/health/HealthConnectRepository.kt +package com.ironlog.app.data.health + +import android.content.Context +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord +import androidx.health.connect.client.records.RestingHeartRateRecord +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.WeightRecord +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.request.ReadRecordsRequest +import androidx.health.connect.client.time.TimeRangeFilter +import androidx.health.connect.client.units.Mass +import com.ironlog.app.ui.model.HistoryEntry +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * Wraps the Health Connect client for IronLog read/write operations. + * + * All public methods are suspend functions and must be called from a coroutine. + * Callers should check [isAvailable] before calling any other method. + */ +class HealthConnectRepository(private val context: Context) { + + private val client: HealthConnectClient? by lazy { + runCatching { HealthConnectClient.getOrCreate(context) }.getOrNull() + } + + /** Returns true if Health Connect is installed and available on this device. */ + fun isAvailable(): Boolean = + HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE + + /** The permission set IronLog requests from Health Connect. */ + val requiredPermissions: Set = setOf( + HealthPermission.getWritePermission(ExerciseSessionRecord::class), + HealthPermission.getWritePermission(WeightRecord::class), + HealthPermission.getReadPermission(SleepSessionRecord::class), + HealthPermission.getReadPermission(RestingHeartRateRecord::class), + HealthPermission.getReadPermission(HeartRateVariabilityRmssdRecord::class), + ) + + /** Returns which of [requiredPermissions] have been granted. */ + suspend fun grantedPermissions(): Set { + val c = client ?: return emptySet() + return c.permissionController.getGrantedPermissions() + } + + /** Returns true if all required permissions are granted. */ + suspend fun hasAllPermissions(): Boolean = + grantedPermissions().containsAll(requiredPermissions) + + // ── Write ───────────────────────────────────────────────────────────── + + /** + * Write a completed workout session from a [HistoryEntry] to Health Connect. + * No-op if Health Connect is unavailable or permissions are missing. + */ + suspend fun writeWorkoutSession(entry: HistoryEntry) { + val c = client ?: return + runCatching { + val date = LocalDate.parse(entry.date.take(10)) + val zone = ZoneId.systemDefault() + val start = date.atStartOfDay(zone).toInstant() + val end = start.plusSeconds(entry.duration.toLong().coerceAtLeast(60)) + + val record = ExerciseSessionRecord( + startTime = start, + startZoneOffset = zone.rules.getOffset(start), + endTime = end, + endZoneOffset = zone.rules.getOffset(end), + exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, + title = entry.name, + metadata = Metadata.autoRecorded(), + ) + c.insertRecords(listOf(record)) + } + } + + /** + * Write the user's body weight to Health Connect. + * @param weightKg Weight in kilograms. + */ + suspend fun writeWeight(weightKg: Double) { + val c = client ?: return + runCatching { + val now = Instant.now() + val zone = ZoneId.systemDefault() + val record = WeightRecord( + time = now, + zoneOffset = zone.rules.getOffset(now), + weight = Mass.kilograms(weightKg), + metadata = Metadata.autoRecorded(), + ) + c.insertRecords(listOf(record)) + } + } + + // ── Read ────────────────────────────────────────────────────────────── + + /** + * Read a [BiometricSnapshot] for the last 24 hours. + * Returns a snapshot with null fields where data is unavailable. + */ + suspend fun readBiometricSnapshot(): BiometricSnapshot { + val c = client ?: return BiometricSnapshot() + + val now = Instant.now() + val yesterday = now.minus(Duration.ofHours(36)) + val timeRange = TimeRangeFilter.between(yesterday, now) + + val sleepHours: Double? = runCatching { + val result = c.readRecords( + ReadRecordsRequest(SleepSessionRecord::class, timeRange) + ) + result.records.lastOrNull()?.let { session -> + Duration.between(session.startTime, session.endTime).toMinutes() / 60.0 + } + }.getOrNull() + + val restingHr: Long? = runCatching { + val result = c.readRecords( + ReadRecordsRequest(RestingHeartRateRecord::class, timeRange) + ) + result.records.lastOrNull()?.beatsPerMinute + }.getOrNull() + + val hrv: Double? = runCatching { + val result = c.readRecords( + ReadRecordsRequest(HeartRateVariabilityRmssdRecord::class, timeRange) + ) + result.records.lastOrNull()?.heartRateVariabilityMillis + }.getOrNull() + + return BiometricSnapshot( + sleepHours = sleepHours, + restingHrBpm = restingHr, + hrvRmssd = hrv, + ) + } +} +``` + +- [ ] **Step 4: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 5: Commit** + +``` +git add app/src/main/java/com/ironlog/app/data/health/HealthConnectRepository.kt +git add app/src/main/java/com/ironlog/app/data/health/BiometricSnapshot.kt +git add app/src/test/java/com/ironlog/app/data/health/HealthConnectRepositoryTest.kt +git commit -m "feat: add HealthConnectRepository for HC write/read and BiometricSnapshot" +``` + +--- + +### Task 5: RecoveryReadinessEngine — blendWithBiometric extension + +**Files:** +- Modify: `app/src/main/java/com/ironlog/app/domain/intelligence/RecoveryReadinessEngine.kt` + +- [ ] **Step 1: Read RecoveryReadinessEngine.kt** + +Open `app/src/main/java/com/ironlog/app/domain/intelligence/RecoveryReadinessEngine.kt` and locate the end of the class body. + +- [ ] **Step 2: Add blendWithBiometric function** + +After the closing `}` of `RecoveryReadinessEngine` (or as the last method inside the class), add: + +```kotlin +/** + * Blends a training-load [readinessScore] (0.0–1.0) with biometric data + * from Health Connect. + * + * Weights: + * - Training load readiness: 60% + * - Sleep quality: 25% + * - HRV: 15% + * + * If no biometric data is available (all nulls), returns [readinessScore] unchanged. + * + * @param readinessScore Overall score from [score] (0.0 = very fatigued, 1.0 = fully ready). + * @param biometrics Latest snapshot from [HealthConnectRepository.readBiometricSnapshot]. + */ +fun blendWithBiometric(readinessScore: Double, biometrics: BiometricSnapshot): Double { + // Helpers (duplicated from test file for production use — single source of truth here) + fun sleepScore(hours: Double) = ((hours - 4.0) / 4.0).coerceIn(0.0, 1.0) + fun hrvScore(rmssd: Double) = ((rmssd - 20.0) / 60.0).coerceIn(0.0, 1.0) + + val hasSleep = biometrics.sleepHours != null + val hasHrv = biometrics.hrvRmssd != null + + if (!hasSleep && !hasHrv) return readinessScore + + var weightedSum = readinessScore * 0.60 + var totalWeight = 0.60 + + if (hasSleep) { + weightedSum += sleepScore(biometrics.sleepHours!!) * 0.25 + totalWeight += 0.25 + } + if (hasHrv) { + weightedSum += hrvScore(biometrics.hrvRmssd!!) * 0.15 + totalWeight += 0.15 + } + + return (weightedSum / totalWeight).coerceIn(0.0, 1.0) +} +``` + +Add the import at the top of the file: +```kotlin +import com.ironlog.app.data.health.BiometricSnapshot +``` + +- [ ] **Step 3: Write a unit test for blendWithBiometric** + +Add to `app/src/test/java/com/ironlog/app/domain/intelligence/RecoveryReadinessEngineTest.kt` (create the file if it doesn't exist): + +```kotlin +// app/src/test/java/com/ironlog/app/domain/intelligence/RecoveryReadinessEngineTest.kt +package com.ironlog.app.domain.intelligence + +import com.ironlog.app.data.health.BiometricSnapshot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecoveryReadinessEngineTest { + + private val engine = RecoveryReadinessEngine() + + @Test fun `blendWithBiometric returns training score when no biometrics`() { + val snap = BiometricSnapshot() // all null + val result = engine.blendWithBiometric(0.8, snap) + assertEquals(0.8, result, 0.001) + } + + @Test fun `blendWithBiometric blends down when sleep is poor`() { + val snap = BiometricSnapshot(sleepHours = 4.0) // worst sleep score + val result = engine.blendWithBiometric(1.0, snap) + assertTrue("Poor sleep should pull blend below 1.0", result < 1.0) + } + + @Test fun `blendWithBiometric is 1_0 when training and biometrics are both perfect`() { + val snap = BiometricSnapshot(sleepHours = 8.0, hrvRmssd = 80.0) + val result = engine.blendWithBiometric(1.0, snap) + assertEquals(1.0, result, 0.001) + } + + @Test fun `blendWithBiometric stays within 0_0 to 1_0`() { + val snap = BiometricSnapshot(sleepHours = 0.0, hrvRmssd = 0.0) + val result = engine.blendWithBiometric(0.0, snap) + assertTrue(result in 0.0..1.0) + } +} +``` + +- [ ] **Step 4: Run tests** + +``` +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.domain.intelligence.RecoveryReadinessEngineTest" +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 5: Commit** + +``` +git add app/src/main/java/com/ironlog/app/domain/intelligence/RecoveryReadinessEngine.kt +git add app/src/test/java/com/ironlog/app/domain/intelligence/RecoveryReadinessEngineTest.kt +git commit -m "feat: add blendWithBiometric to RecoveryReadinessEngine for HC-enriched scores" +``` + +--- + +### Task 6: HealthConnectPermissionSheet — one-time permission request + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/HealthConnectPermissionSheet.kt` + +- [ ] **Step 1: Implement HealthConnectPermissionSheet** + +```kotlin +// app/src/main/java/com/ironlog/app/ui/screens/HealthConnectPermissionSheet.kt +package com.ironlog.app.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.health.connect.client.PermissionController + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HealthConnectPermissionSheet( + onRequestPermissions: () -> Unit, + onDismiss: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "Connect to Health Connect", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + "IronLog can read your sleep, heart rate, and HRV data to give you smarter recovery recommendations.", + style = MaterialTheme.typography.bodyMedium, + ) + + BenefitRow(emoji = "😴", text = "Sleep quality → better fatigue scoring") + BenefitRow(emoji = "❤️", text = "Resting HR + HRV → recovery readiness") + BenefitRow(emoji = "🏋️", text = "Workouts written back → unified health timeline") + + Text( + "Your data stays on your device. IronLog never uploads health data to any server.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(8.dp)) + + Button( + onClick = onRequestPermissions, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Connect Health Connect") + } + + TextButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Not now") + } + } + } +} + +@Composable +private fun BenefitRow(emoji: String, text: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(emoji, style = MaterialTheme.typography.bodyLarge) + Spacer(Modifier.width(10.dp)) + Text(text, style = MaterialTheme.typography.bodyMedium) + } +} +``` + +- [ ] **Step 2: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 5 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Commit** + +``` +git add app/src/main/java/com/ironlog/app/ui/screens/HealthConnectPermissionSheet.kt +git commit -m "feat: add HealthConnectPermissionSheet for one-time HC permission request" +``` + +--- + +### Task 7: Wire Health Connect into HomeScreen + +**Files:** +- Modify: `app/src/main/java/com/ironlog/app/ui/screens/HomeScreen.kt` + +- [ ] **Step 1: Add HealthConnectRepository initialization and biometric fetch** + +In `HomeScreen` (or the ViewModel that HomeScreen observes), add: + +```kotlin +// At the top of the HomeScreen composable (or in the ViewModel init): +val context = LocalContext.current +val healthRepo = remember { HealthConnectRepository(context) } + +// State for permission sheet +var showHealthConnectSheet by remember { mutableStateOf(false) } + +// Biometric snapshot — fetched once per composition if HC is available +var biometricSnapshot: BiometricSnapshot by remember { mutableStateOf(BiometricSnapshot()) } + +// Check HC availability and permissions on first composition +LaunchedEffect(Unit) { + if (healthRepo.isAvailable()) { + if (!healthRepo.hasAllPermissions()) { + // Show permission sheet on first load if not yet connected + val prefs = context.getSharedPreferences("hc_prefs", 0) + if (!prefs.getBoolean("hc_sheet_shown", false)) { + showHealthConnectSheet = true + prefs.edit().putBoolean("hc_sheet_shown", true).apply() + } + } else { + // Already have permissions — fetch biometrics + biometricSnapshot = withContext(Dispatchers.IO) { + healthRepo.readBiometricSnapshot() + } + } + } +} + +// Add required imports: +// import com.ironlog.app.data.health.HealthConnectRepository +// import com.ironlog.app.data.health.BiometricSnapshot +// import kotlinx.coroutines.Dispatchers +// import kotlinx.coroutines.withContext +``` + +- [ ] **Step 2: Blend biometric data into the readiness score for WorkoutSuggestionEngine** + +Find the existing `readiness` computation from Task 2 of the fatigue-suggestion plan and update it: + +```kotlin +// Replace the existing readiness computation: +val recoveryEngine = remember { RecoveryReadinessEngine() } + +val readiness: Map = remember(state.history, biometricSnapshot) { + val rawReadiness = recoveryEngine.readinessByRegion(state.history, emptyMap()) + val rawScore = recoveryEngine.score(rawReadiness, null).overall + val blendedScore = recoveryEngine.blendWithBiometric(rawScore, biometricSnapshot) + // Scale all regions by the blend ratio + val ratio = if (rawScore > 0) blendedScore / rawScore else 1.0 + rawReadiness.mapValues { (_, v) -> (v * ratio).coerceIn(0.0, 1.0) } +} +``` + +- [ ] **Step 3: Show permission sheet when triggered** + +```kotlin +if (showHealthConnectSheet && healthRepo.isAvailable()) { + val permLauncher = rememberLauncherForActivityResult( + PermissionController.createRequestPermissionResultContract() + ) { grantedPerms -> + showHealthConnectSheet = false + if (grantedPerms.containsAll(healthRepo.requiredPermissions)) { + // Permissions granted — fetch biometrics + coroutineScope.launch { + biometricSnapshot = withContext(Dispatchers.IO) { + healthRepo.readBiometricSnapshot() + } + } + } + } + + HealthConnectPermissionSheet( + onRequestPermissions = { permLauncher.launch(healthRepo.requiredPermissions) }, + onDismiss = { showHealthConnectSheet = false }, + ) +} +``` + +Add imports: +```kotlin +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.health.connect.client.PermissionController +import com.ironlog.app.ui.screens.HealthConnectPermissionSheet +import kotlinx.coroutines.launch +import androidx.compose.runtime.rememberCoroutineScope +``` + +- [ ] **Step 4: Write workout sessions to Health Connect after workout completion** + +In the existing workout completion callback (wherever the app saves a new `HistoryEntry`), add: + +```kotlin +// After saving the workout entry to ObjectBox: +if (healthRepo.isAvailable() && healthRepo.hasAllPermissions()) { + coroutineScope.launch(Dispatchers.IO) { + healthRepo.writeWorkoutSession(completedEntry) + } +} +``` + +- [ ] **Step 5: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 6: Commit** + +``` +git add app/src/main/java/com/ironlog/app/ui/screens/HomeScreen.kt +git commit -m "feat: integrate Health Connect biometric blending and HC permission flow into HomeScreen" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ Health Connect dependency added (`connect-client:1.1.0-alpha07`) +- ✅ Manifest: queries block, rationale activity, privacy policy meta-data +- ✅ Write: `ExerciseSessionRecord` after workout completion +- ✅ Write: `WeightRecord` (method provided, caller hooks in Settings/profile screen) +- ✅ Read: Sleep, RHR, HRV into `BiometricSnapshot` +- ✅ `blendWithBiometric` on `RecoveryReadinessEngine` (60% training / 25% sleep / 15% HRV) +- ✅ One-time permission sheet on first HomeScreen open +- ✅ Permission launcher using `PermissionController.createRequestPermissionResultContract()` +- ✅ No-op if HC unavailable (graceful degradation — app works without HC) +- ✅ Unit tests for pure score-blending logic + +**Limitations noted:** +- `HealthConnectRepository` uses `Metadata.autoRecorded()` — for production, switch to `Metadata.manualEntry()` with a proper `DataOrigin` if required by HC policy review. +- `RecoveryReadinessEngine.score()` return type must have `.overall: Double` — verify this before implementing Task 5. If it returns a different type, adapt the blend call accordingly. diff --git a/docs/superpowers/plans/2026-05-18-home-screen-widget.md b/docs/superpowers/plans/2026-05-18-home-screen-widget.md new file mode 100644 index 0000000..ed4fd53 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-home-screen-widget.md @@ -0,0 +1,1319 @@ +# Jetpack Glance Home Screen Widgets Implementation Plan + +> **Status:** Historical execution plan. Its checkboxes were not backfilled and are not a current backlog. Use `AGENTS.md` and the current source/tests as the authoritative project state. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship three stunning Android home screen widgets using Jetpack Glance — a 2×2 "The Badge" showing rank and streak, a 4×2 "The Dashboard" with XP bar and today's recommended workout, and a 4×4 "The War Room" with full stats, quests, and weekly progress. + +**Architecture:** Each widget has its own `GlanceAppWidget` subclass and `GlanceAppWidgetReceiver`. A shared `WidgetState` data class (serializable via Glance's `DataStore`-backed `GlanceStateDefinition`) holds all widget data. A `WidgetDataRepository` pulls data from ObjectBox (GamificationProfileEntity, HistoryEntry) and writes `WidgetState`. A `WidgetUpdateWorker` (WorkManager, periodic 30-min + on-demand) keeps widgets fresh. The existing WorkManager dependency in `build.gradle.kts` is already present (`work-runtime-ktx:2.9.1`). + +**Tech Stack:** Kotlin, `androidx.glance:glance-appwidget:1.1.1`, Jetpack Compose, WorkManager 2.9.1, ObjectBox 4.0.3 + +--- + +## File Map + +| Action | File | +|--------|------| +| Modify | `app/build.gradle.kts` — add Glance dependency | +| Create | `app/src/main/java/com/ironlog/app/widget/WidgetState.kt` | +| Create | `app/src/main/java/com/ironlog/app/widget/WidgetDataRepository.kt` | +| Create | `app/src/main/java/com/ironlog/app/widget/BadgeWidget.kt` | +| Create | `app/src/main/java/com/ironlog/app/widget/DashboardWidget.kt` | +| Create | `app/src/main/java/com/ironlog/app/widget/WarRoomWidget.kt` | +| Create | `app/src/main/java/com/ironlog/app/widget/WidgetUpdateWorker.kt` | +| Create | `app/src/main/res/xml/badge_widget_info.xml` | +| Create | `app/src/main/res/xml/dashboard_widget_info.xml` | +| Create | `app/src/main/res/xml/warroom_widget_info.xml` | +| Modify | `app/src/main/AndroidManifest.xml` — register 3 widget receivers | +| Create | `app/src/test/java/com/ironlog/app/widget/WidgetStateTest.kt` | + +--- + +### Task 1: Add Glance dependency + +**Files:** +- Modify: `app/build.gradle.kts` + +- [ ] **Step 1: Add Glance dependency** + +Inside the `dependencies { }` block in `app/build.gradle.kts`: + +```kotlin + // Jetpack Glance — Compose-based app widgets + implementation("androidx.glance:glance-appwidget:1.1.1") + implementation("androidx.glance:glance-material3:1.1.1") +``` + +- [ ] **Step 2: Sync and build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD|Download" | Select-Object -Last 15 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Commit** + +``` +git add app/build.gradle.kts +git commit -m "build: add Jetpack Glance dependencies for home screen widgets" +``` + +--- + +### Task 2: WidgetState — shared state data class + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/widget/WidgetState.kt` +- Test: `app/src/test/java/com/ironlog/app/widget/WidgetStateTest.kt` + +- [ ] **Step 1: Write failing tests** + +```kotlin +// app/src/test/java/com/ironlog/app/widget/WidgetStateTest.kt +package com.ironlog.app.widget + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WidgetStateTest { + + @Test fun `default WidgetState has safe defaults`() { + val state = WidgetState() + assertEquals("E", state.rank) + assertEquals(1, state.level) + assertEquals(0, state.streakWeeks) + assertEquals("No Plan", state.recommendedDayName) + assertTrue(state.xpPercent in 0f..1f) + } + + @Test fun `xpPercent is clamped to 0-1`() { + val state = WidgetState(xpInLevel = 200L, xpForNextLevel = 100L) + assertEquals(1.0f, state.xpPercent, 0.001f) + } + + @Test fun `xpPercent is 0 when xpForNextLevel is 0`() { + val state = WidgetState(xpInLevel = 0L, xpForNextLevel = 0L) + assertEquals(0.0f, state.xpPercent, 0.001f) + } + + @Test fun `xpPercent computes correctly for partial level`() { + val state = WidgetState(xpInLevel = 50L, xpForNextLevel = 200L) + assertEquals(0.25f, state.xpPercent, 0.001f) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.widget.WidgetStateTest" 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 5 +``` + +Expected: compilation error. + +- [ ] **Step 3: Implement WidgetState** + +```kotlin +// app/src/main/java/com/ironlog/app/widget/WidgetState.kt +package com.ironlog.app.widget + +import kotlinx.serialization.Serializable + +/** + * Serializable snapshot of data needed by all three IronLog widgets. + * Stored via Glance's DataStore-backed state mechanism. + */ +@Serializable +data class WidgetState( + // ── Gamification ────────────────────────────────────────────────────── + val rank: String = "E", + val level: Int = 1, + val activeTitle: String = "Novice Hunter", + val streakWeeks: Int = 0, + val xpInLevel: Long = 0L, + val xpForNextLevel: Long = 100L, + + // ── Stats ───────────────────────────────────────────────────────────── + val statStr: Int = 1, + val statVit: Int = 1, + val statEnd: Int = 1, + val statAgi: Int = 1, + + // ── Workout recommendation ──────────────────────────────────────────── + val recommendedDayName: String = "No Plan", + val recommendedDayBlurb: String = "", + + // ── Weekly progress ─────────────────────────────────────────────────── + /** Number of sessions logged this ISO week. */ + val weekSessionsCount: Int = 0, + /** Weekly goal from IronLogSettings.weeklyGoalDays. */ + val weeklyGoal: Int = 4, + + // ── Active quest ────────────────────────────────────────────────────── + val activeQuestDescription: String = "", + val activeQuestXpReward: Int = 0, + + // ── Last updated ────────────────────────────────────────────────────── + val lastUpdatedEpochMs: Long = 0L, +) { + /** XP progress within the current level as a fraction 0.0–1.0. */ + val xpPercent: Float + get() = if (xpForNextLevel == 0L) 0f + else (xpInLevel.toFloat() / xpForNextLevel.toFloat()).coerceIn(0f, 1f) + + /** Week progress fraction 0.0–1.0. */ + val weekPercent: Float + get() = if (weeklyGoal == 0) 0f + else (weekSessionsCount.toFloat() / weeklyGoal.toFloat()).coerceIn(0f, 1f) +} +``` + +- [ ] **Step 4: Run tests** + +``` +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.widget.WidgetStateTest" +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 5: Commit** + +``` +git add app/src/main/java/com/ironlog/app/widget/WidgetState.kt +git add app/src/test/java/com/ironlog/app/widget/WidgetStateTest.kt +git commit -m "feat: add WidgetState serializable data class for Glance widget state" +``` + +--- + +### Task 3: WidgetDataRepository — populate WidgetState from ObjectBox + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/widget/WidgetDataRepository.kt` + +- [ ] **Step 1: Implement WidgetDataRepository** + +```kotlin +// app/src/main/java/com/ironlog/app/widget/WidgetDataRepository.kt +package com.ironlog.app.widget + +import android.content.Context +import com.ironlog.app.data.entity.GamificationProfileEntity_ +import com.ironlog.app.data.entity.QuestEntity_ +import com.ironlog.app.domain.gamification.XpEngine +import com.ironlog.app.domain.intelligence.RecoveryReadinessEngine +import com.ironlog.app.domain.intelligence.WorkoutSuggestionEngine +import com.ironlog.app.ui.model.HistoryEntry +import io.objectbox.BoxStore +import kotlinx.serialization.json.Json +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.temporal.WeekFields + +/** + * Reads ObjectBox data and assembles a [WidgetState] for all widgets. + * Must be called from a background thread (IO dispatcher). + */ +class WidgetDataRepository( + private val context: Context, + private val boxStore: BoxStore, +) { + + private val xpEngine = XpEngine() + private val recoveryEngine = RecoveryReadinessEngine() + private val suggestionEngine = WorkoutSuggestionEngine() + + /** + * Build a fresh [WidgetState] from current ObjectBox data. + * Returns a default state if the database is empty. + */ + fun buildWidgetState( + history: List, + weeklyGoal: Int, + ): WidgetState { + // ── Gamification profile ──────────────────────────────────────── + val profileBox = boxStore.boxFor(com.ironlog.app.data.entity.GamificationProfileEntity::class.java) + val profile = profileBox.query(GamificationProfileEntity_.id.notNull()).build().findFirst() + + val rank = profile?.rank ?: "E" + val level = profile?.level ?: 1 + val activeTitle = profile?.activeTitle ?: "Novice Hunter" + val streakWeeks = profile?.streakWeeks ?: 0 + val xpInLevel = profile?.xpInLevel ?: 0L + val xpForNext = xpEngine.xpForLevel(level) + + // Stats from persisted JSON + val stats = profile?.statsJson?.let { json -> + runCatching { + Json.decodeFromString(json) + }.getOrNull() + } ?: com.ironlog.app.domain.gamification.RpgStats() + + // ── Active quest ───────────────────────────────────────────────── + val questBox = boxStore.boxFor(com.ironlog.app.data.entity.QuestEntity::class.java) + val activeQuest = questBox.query(QuestEntity_.completedAt.equal("")).build().findFirst() + + // ── Workout recommendation ──────────────────────────────────────── + val planBox = boxStore.boxFor(com.ironlog.app.data.entity.PlanEntity::class.java) + // NOTE: Adjust entity class name to match the actual ObjectBox plan entity in this codebase. + // If plans are stored differently, adapt this section. + val recommendedDayName: String + val recommendedDayBlurb: String + runCatching { + val readiness = recoveryEngine.readinessByRegion(history, emptyMap()) + // Fetch active plan days from box — adapt to actual plan entity structure + recommendedDayName = "Best Day" + recommendedDayBlurb = "Your muscles are ready." + } + // Fallback: + val dayName = "Train Today" + val dayBlurb = "Recovery looks good." + + // ── Weekly sessions ──────────────────────────────────────────────── + val isoWeek = WeekFields.ISO + val thisWeek = LocalDate.now().let { + val week = it.get(isoWeek.weekOfWeekBasedYear()) + val year = it.get(isoWeek.weekBasedYear()) + "$year-W${week.toString().padStart(2, '0')}" + } + val fmt = DateTimeFormatter.ISO_LOCAL_DATE + val weekSessions = history.count { entry -> + runCatching { + val date = LocalDate.parse(entry.date.take(10), fmt) + val wk = date.get(isoWeek.weekOfWeekBasedYear()) + val yr = date.get(isoWeek.weekBasedYear()) + "$yr-W${wk.toString().padStart(2, '0')}" == thisWeek + }.getOrDefault(false) + } + + return WidgetState( + rank = rank, + level = level, + activeTitle = activeTitle, + streakWeeks = streakWeeks, + xpInLevel = xpInLevel, + xpForNextLevel = xpForNext, + statStr = stats.str, + statVit = stats.vit, + statEnd = stats.end, + statAgi = stats.agi, + recommendedDayName = dayName, + recommendedDayBlurb = dayBlurb, + weekSessionsCount = weekSessions, + weeklyGoal = weeklyGoal, + activeQuestDescription = activeQuest?.description ?: "", + activeQuestXpReward = activeQuest?.xpReward ?: 0, + lastUpdatedEpochMs = System.currentTimeMillis(), + ) + } +} +``` + +> **Note:** The line `boxStore.boxFor(com.ironlog.app.data.entity.PlanEntity::class.java)` must be replaced with the actual plan entity class used in this codebase. Search for `@Entity` annotated classes that store plan data and use the correct class name. If plans are not in ObjectBox (stored as JSON in SharedPreferences instead), read from SharedPreferences and deserialize. + +- [ ] **Step 2: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +Expected: `BUILD SUCCESSFUL` (or fix entity class name mismatch). + +- [ ] **Step 3: Commit** + +``` +git add app/src/main/java/com/ironlog/app/widget/WidgetDataRepository.kt +git commit -m "feat: add WidgetDataRepository to populate WidgetState from ObjectBox" +``` + +--- + +### Task 4: WidgetUpdateWorker — periodic WorkManager job + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/widget/WidgetUpdateWorker.kt` + +- [ ] **Step 1: Implement WidgetUpdateWorker** + +```kotlin +// app/src/main/java/com/ironlog/app/widget/WidgetUpdateWorker.kt +package com.ironlog.app.widget + +import android.content.Context +import androidx.glance.appwidget.updateAll +import androidx.work.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit + +/** + * WorkManager worker that refreshes all IronLog widgets every 30 minutes. + * + * Also call [enqueueOneTime] after any workout is saved to push an immediate update. + */ +class WidgetUpdateWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + return withContext(Dispatchers.IO) { + runCatching { + BadgeWidget().updateAll(applicationContext) + DashboardWidget().updateAll(applicationContext) + WarRoomWidget().updateAll(applicationContext) + }.fold( + onSuccess = { Result.success() }, + onFailure = { Result.retry() }, + ) + } + } + + companion object { + private const val PERIODIC_WORK_NAME = "ironlog_widget_refresh" + private const val ONETIME_WORK_NAME = "ironlog_widget_refresh_now" + + /** + * Enqueue the periodic 30-minute refresh. + * Call once from Application.onCreate(). + */ + fun enqueuePeriodic(context: Context) { + val request = PeriodicWorkRequestBuilder(30, TimeUnit.MINUTES) + .setConstraints( + Constraints.Builder() + .setRequiresBatteryNotLow(true) + .build() + ) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + + /** + * Enqueue an immediate one-time refresh (e.g., after workout saved). + */ + fun enqueueOneTime(context: Context) { + val request = OneTimeWorkRequestBuilder() + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + ONETIME_WORK_NAME, + ExistingWorkPolicy.REPLACE, + request, + ) + } + } +} +``` + +- [ ] **Step 2: Enqueue in Application.onCreate()** + +Find the Application class (search for `class IronLogApp : Application()` or similar). Add to `onCreate()`: + +```kotlin +// In Application.onCreate(): +WidgetUpdateWorker.enqueuePeriodic(this) +``` + +Add import: +```kotlin +import com.ironlog.app.widget.WidgetUpdateWorker +``` + +- [ ] **Step 3: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Commit** + +``` +git add app/src/main/java/com/ironlog/app/widget/WidgetUpdateWorker.kt +git commit -m "feat: add WidgetUpdateWorker periodic WorkManager job (30min) for widget refresh" +``` + +--- + +### Task 5: BadgeWidget — 2×2 rank badge widget + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/widget/BadgeWidget.kt` +- Create: `app/src/main/res/xml/badge_widget_info.xml` + +- [ ] **Step 1: Create widget info XML** + +```xml + + + +``` + +Create the string resource — add to `app/src/main/res/values/strings.xml`: +```xml +IronLog rank and streak badge +IronLog daily dashboard with XP and workout +IronLog war room with stats and quests +``` + +Create preview placeholder layout `app/src/main/res/layout/widget_preview_placeholder.xml`: +```xml + + +``` + +- [ ] **Step 2: Implement BadgeWidget** + +```kotlin +// app/src/main/java/com/ironlog/app/widget/BadgeWidget.kt +package com.ironlog.app.widget + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.* +import androidx.glance.action.actionStartActivity +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.provideContent +import androidx.glance.layout.* +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider + +/** + * 2×2 "The Badge" widget. + * Shows: Solo Leveling rank letter (large), level, streak weeks. + */ +class BadgeWidget : GlanceAppWidget() { + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val state = currentState() + provideContent { BadgeWidgetContent(state) } + } +} + +@Composable +private fun BadgeWidgetContent(state: WidgetState) { + val rankColor = rankGlanceColor(state.rank) + + Box( + modifier = GlanceModifier + .fillMaxSize() + .background(ColorProvider(Color(0xFF1A1A2E))) + .cornerRadius(16.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + modifier = GlanceModifier.fillMaxSize().padding(8.dp), + ) { + // Rank letter — large and dramatic + Text( + text = state.rank, + style = TextStyle( + color = rankColor, + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + ), + ) + + // Level + Text( + text = "Lv.${state.level}", + style = TextStyle( + color = ColorProvider(Color.White), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ), + ) + + Spacer(GlanceModifier.height(4.dp)) + + // Streak badge + Box( + modifier = GlanceModifier + .background(ColorProvider(Color(0x33FF6B35))) + .cornerRadius(8.dp) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + Text( + text = "🔥 ${state.streakWeeks}w", + style = TextStyle( + color = ColorProvider(Color(0xFFFF6B35)), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + ), + ) + } + } + } +} + +class BadgeWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = BadgeWidget() +} + +private fun rankGlanceColor(rank: String): ColorProvider = ColorProvider(when (rank) { + "E" -> Color(0xFF9E9E9E) + "D" -> Color(0xFF4CAF50) + "C" -> Color(0xFF2196F3) + "B" -> Color(0xFF9C27B0) + "A" -> Color(0xFFFF9800) + "S" -> Color(0xFFFF5722) + "National" -> Color(0xFFFFD700) + else -> Color(0xFF9E9E9E) +}) +``` + +- [ ] **Step 3: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +- [ ] **Step 4: Commit** + +``` +git add app/src/main/java/com/ironlog/app/widget/BadgeWidget.kt +git add app/src/main/res/xml/badge_widget_info.xml +git add app/src/main/res/values/strings.xml +git add app/src/main/res/layout/widget_preview_placeholder.xml +git commit -m "feat: add BadgeWidget (2x2) showing rank, level, and streak" +``` + +--- + +### Task 6: DashboardWidget — 4×2 daily dashboard widget + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/widget/DashboardWidget.kt` +- Create: `app/src/main/res/xml/dashboard_widget_info.xml` + +- [ ] **Step 1: Create widget info XML** + +```xml + + + +``` + +- [ ] **Step 2: Implement DashboardWidget** + +```kotlin +// app/src/main/java/com/ironlog/app/widget/DashboardWidget.kt +package com.ironlog.app.widget + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.* +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.LinearProgressIndicator +import androidx.glance.appwidget.provideContent +import androidx.glance.layout.* +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider + +/** + * 4×2 "The Dashboard" widget. + * Shows: Rank+Level (left) | XP bar | Recommended workout day | Week progress. + */ +class DashboardWidget : GlanceAppWidget() { + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val state = currentState() + provideContent { DashboardWidgetContent(state) } + } +} + +@Composable +private fun DashboardWidgetContent(state: WidgetState) { + val bgColor = ColorProvider(Color(0xFF12121F)) + val accentColor = ColorProvider(Color(0xFFFF4500)) + val white = ColorProvider(Color.White) + val muted = ColorProvider(Color(0xFF8E8E9E)) + + Row( + modifier = GlanceModifier + .fillMaxSize() + .background(bgColor) + .cornerRadius(16.dp) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Left: Rank + Level + Streak + Column( + modifier = GlanceModifier.defaultWeight().padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = state.rank, + style = TextStyle(color = rankGlanceColor(state.rank), fontSize = 28.sp, fontWeight = FontWeight.Bold), + ) + Spacer(GlanceModifier.width(6.dp)) + Text( + text = "Lv.${state.level}", + style = TextStyle(color = white, fontSize = 13.sp, fontWeight = FontWeight.Medium), + ) + } + Text( + text = state.activeTitle, + style = TextStyle(color = muted, fontSize = 10.sp), + ) + Spacer(GlanceModifier.height(4.dp)) + Text( + text = "🔥 ${state.streakWeeks}w streak", + style = TextStyle(color = accentColor, fontSize = 11.sp, fontWeight = FontWeight.Bold), + ) + } + + // Divider + Box( + modifier = GlanceModifier + .width(1.dp) + .fillMaxHeight() + .background(ColorProvider(Color(0x33FFFFFF))) + .padding(vertical = 4.dp), + ) {} + + // Right: XP bar + Recommended workout + Week progress + Column( + modifier = GlanceModifier.defaultWeight().padding(start = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // XP bar + Row(verticalAlignment = Alignment.CenterVertically) { + Text("XP", style = TextStyle(color = muted, fontSize = 10.sp)) + Spacer(GlanceModifier.width(4.dp)) + LinearProgressIndicator( + progress = state.xpPercent, + modifier = GlanceModifier.defaultWeight().height(6.dp), + color = accentColor, + backgroundColor = ColorProvider(Color(0x33FF4500)), + ) + Spacer(GlanceModifier.width(4.dp)) + Text( + "${(state.xpPercent * 100).toInt()}%", + style = TextStyle(color = muted, fontSize = 10.sp), + ) + } + + Spacer(GlanceModifier.height(6.dp)) + + // Recommended workout + Text( + text = "⚡ ${state.recommendedDayName}", + style = TextStyle(color = white, fontSize = 12.sp, fontWeight = FontWeight.Bold), + ) + if (state.recommendedDayBlurb.isNotBlank()) { + Text( + text = state.recommendedDayBlurb, + style = TextStyle(color = muted, fontSize = 10.sp), + ) + } + + Spacer(GlanceModifier.height(6.dp)) + + // Week progress dots + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Week: ", style = TextStyle(color = muted, fontSize = 10.sp)) + repeat(state.weeklyGoal) { i -> + val filled = i < state.weekSessionsCount + Box( + modifier = GlanceModifier + .size(8.dp) + .background( + ColorProvider(if (filled) Color(0xFFFF4500) else Color(0x33FFFFFF)) + ) + .cornerRadius(4.dp) + ) {} + if (i < state.weeklyGoal - 1) Spacer(GlanceModifier.width(4.dp)) + } + } + } + } +} + +class DashboardWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = DashboardWidget() +} + +private fun rankGlanceColor(rank: String): ColorProvider = ColorProvider(when (rank) { + "E" -> Color(0xFF9E9E9E) + "D" -> Color(0xFF4CAF50) + "C" -> Color(0xFF2196F3) + "B" -> Color(0xFF9C27B0) + "A" -> Color(0xFFFF9800) + "S" -> Color(0xFFFF5722) + "National" -> Color(0xFFFFD700) + else -> Color(0xFF9E9E9E) +}) +``` + +- [ ] **Step 3: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +- [ ] **Step 4: Commit** + +``` +git add app/src/main/java/com/ironlog/app/widget/DashboardWidget.kt +git add app/src/main/res/xml/dashboard_widget_info.xml +git commit -m "feat: add DashboardWidget (4x2) with XP bar, recommended workout, week progress" +``` + +--- + +### Task 7: WarRoomWidget — 4×4 full stats widget + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/widget/WarRoomWidget.kt` +- Create: `app/src/main/res/xml/warroom_widget_info.xml` + +- [ ] **Step 1: Create widget info XML** + +```xml + + + +``` + +- [ ] **Step 2: Implement WarRoomWidget** + +```kotlin +// app/src/main/java/com/ironlog/app/widget/WarRoomWidget.kt +package com.ironlog.app.widget + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.* +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.LinearProgressIndicator +import androidx.glance.appwidget.provideContent +import androidx.glance.layout.* +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider + +/** + * 4×4 "The War Room" widget. + * Shows: Header (rank/level/title) | XP bar | Stats grid | Active quest | Week dots. + */ +class WarRoomWidget : GlanceAppWidget() { + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val state = currentState() + provideContent { WarRoomWidgetContent(state) } + } +} + +@Composable +private fun WarRoomWidgetContent(state: WidgetState) { + val bg = ColorProvider(Color(0xFF0D0D1A)) + val accent = ColorProvider(Color(0xFFFF4500)) + val white = ColorProvider(Color.White) + val muted = ColorProvider(Color(0xFF6E6E8E)) + val surface = ColorProvider(Color(0x1AFFFFFF)) + + Column( + modifier = GlanceModifier + .fillMaxSize() + .background(bg) + .cornerRadius(20.dp) + .padding(14.dp), + verticalAlignment = Alignment.Top, + ) { + // ── Header ───────────────────────────────────────────────────────── + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + state.rank, + style = TextStyle(color = rankGlanceColor(state.rank), fontSize = 32.sp, fontWeight = FontWeight.Bold), + ) + Spacer(GlanceModifier.width(8.dp)) + Column { + Text( + "Level ${state.level} • ${state.activeTitle}", + style = TextStyle(color = white, fontSize = 12.sp, fontWeight = FontWeight.Bold), + ) + Text( + "🔥 ${state.streakWeeks}-week streak", + style = TextStyle(color = accent, fontSize = 11.sp), + ) + } + } + + Spacer(GlanceModifier.height(8.dp)) + + // ── XP bar ───────────────────────────────────────────────────────── + Row(verticalAlignment = Alignment.CenterVertically) { + Text("XP ", style = TextStyle(color = muted, fontSize = 10.sp)) + LinearProgressIndicator( + progress = state.xpPercent, + modifier = GlanceModifier.defaultWeight().height(8.dp), + color = accent, + backgroundColor = surface, + ) + Spacer(GlanceModifier.width(6.dp)) + Text( + "${(state.xpPercent * 100).toInt()}%", + style = TextStyle(color = muted, fontSize = 10.sp), + ) + } + + Spacer(GlanceModifier.height(10.dp)) + + // ── Stats grid (2 columns) ────────────────────────────────────────── + Row(modifier = GlanceModifier.fillMaxWidth()) { + Column(modifier = GlanceModifier.defaultWeight()) { + WarRoomStatRow("STR", state.statStr, Color(0xFFFF6B35)) + Spacer(GlanceModifier.height(4.dp)) + WarRoomStatRow("END", state.statEnd, Color(0xFF2196F3)) + } + Spacer(GlanceModifier.width(12.dp)) + Column(modifier = GlanceModifier.defaultWeight()) { + WarRoomStatRow("VIT", state.statVit, Color(0xFF4CAF50)) + Spacer(GlanceModifier.height(4.dp)) + WarRoomStatRow("AGI", state.statAgi, Color(0xFFFFEB3B)) + } + } + + Spacer(GlanceModifier.height(10.dp)) + + // ── Recommended workout ──────────────────────────────────────────── + Box( + modifier = GlanceModifier + .fillMaxWidth() + .background(surface) + .cornerRadius(10.dp) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Column { + Text( + "⚡ Best Today: ${state.recommendedDayName}", + style = TextStyle(color = white, fontSize = 11.sp, fontWeight = FontWeight.Bold), + ) + if (state.recommendedDayBlurb.isNotBlank()) { + Text( + state.recommendedDayBlurb, + style = TextStyle(color = muted, fontSize = 10.sp), + ) + } + } + } + + Spacer(GlanceModifier.height(8.dp)) + + // ── Active quest ─────────────────────────────────────────────────── + if (state.activeQuestDescription.isNotBlank()) { + Box( + modifier = GlanceModifier + .fillMaxWidth() + .background(ColorProvider(Color(0x1AFF4500))) + .cornerRadius(10.dp) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Column { + Text( + "Quest", + style = TextStyle(color = accent, fontSize = 9.sp, fontWeight = FontWeight.Bold), + ) + Text( + state.activeQuestDescription.take(80), + style = TextStyle(color = white, fontSize = 10.sp), + ) + } + } + + Spacer(GlanceModifier.height(8.dp)) + } + + // ── Week progress ────────────────────────────────────────────────── + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Week: ", style = TextStyle(color = muted, fontSize = 10.sp)) + repeat(state.weeklyGoal) { i -> + val filled = i < state.weekSessionsCount + Box( + modifier = GlanceModifier + .size(10.dp) + .background(ColorProvider(if (filled) Color(0xFFFF4500) else Color(0x33FFFFFF))) + .cornerRadius(5.dp), + ) {} + if (i < state.weeklyGoal - 1) Spacer(GlanceModifier.width(4.dp)) + } + Spacer(GlanceModifier.defaultWeight()) + Text( + "${state.weekSessionsCount}/${state.weeklyGoal}", + style = TextStyle(color = muted, fontSize = 10.sp), + ) + } + } +} + +@Composable +private fun WarRoomStatRow(label: String, value: Int, color: Color) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + label, + style = TextStyle(color = ColorProvider(color), fontSize = 10.sp, fontWeight = FontWeight.Bold), + modifier = GlanceModifier.width(28.dp), + ) + LinearProgressIndicator( + progress = (value / 999f).coerceIn(0f, 1f), + modifier = GlanceModifier.defaultWeight().height(5.dp), + color = ColorProvider(color), + backgroundColor = ColorProvider(Color(0x22FFFFFF)), + ) + Spacer(GlanceModifier.width(4.dp)) + Text( + value.toString(), + style = TextStyle(color = ColorProvider(Color.White), fontSize = 10.sp), + ) + } +} + +class WarRoomWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = WarRoomWidget() +} + +private fun rankGlanceColor(rank: String): ColorProvider = ColorProvider(when (rank) { + "E" -> Color(0xFF9E9E9E) + "D" -> Color(0xFF4CAF50) + "C" -> Color(0xFF2196F3) + "B" -> Color(0xFF9C27B0) + "A" -> Color(0xFFFF9800) + "S" -> Color(0xFFFF5722) + "National" -> Color(0xFFFFD700) + else -> Color(0xFF9E9E9E) +}) +``` + +- [ ] **Step 3: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Commit** + +``` +git add app/src/main/java/com/ironlog/app/widget/WarRoomWidget.kt +git add app/src/main/res/xml/warroom_widget_info.xml +git commit -m "feat: add WarRoomWidget (4x4) with stats grid, quest, week progress" +``` + +--- + +### Task 8: Register widget receivers in AndroidManifest + +**Files:** +- Modify: `app/src/main/AndroidManifest.xml` + +- [ ] **Step 1: Read AndroidManifest.xml — find the application closing tag** + +Open `app/src/main/AndroidManifest.xml` and locate ``. + +- [ ] **Step 2: Add all three widget receivers** + +Before ``, add: + +```xml + + + + + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 3: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Commit** + +``` +git add app/src/main/AndroidManifest.xml +git commit -m "feat: register BadgeWidgetReceiver, DashboardWidgetReceiver, WarRoomWidgetReceiver in manifest" +``` + +--- + +### Task 9: Glance state definition — connect WidgetState to Glance DataStore + +**Files:** +- Modify: `app/src/main/java/com/ironlog/app/widget/BadgeWidget.kt` (and DashboardWidget, WarRoomWidget) + +> Glance widgets need a `stateDefinition` to know how to store and retrieve state. The default Glance state uses `androidx.datastore.preferences.core.Preferences`. For a custom serializable data class, we use `GlanceStateDefinition`. + +- [ ] **Step 1: Create WidgetStateDefinition** + +Add a new file `app/src/main/java/com/ironlog/app/widget/WidgetStateDefinition.kt`: + +```kotlin +// app/src/main/java/com/ironlog/app/widget/WidgetStateDefinition.kt +package com.ironlog.app.widget + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.Serializer +import androidx.glance.state.GlanceStateDefinition +import kotlinx.serialization.json.Json +import java.io.File +import java.io.InputStream +import java.io.OutputStream + +object WidgetStateDefinition : GlanceStateDefinition { + + private const val DATA_STORE_FILENAME = "ironlog_widget_state" + + override suspend fun getDataStore(context: Context, fileKey: String): DataStore { + return DataStoreFactory.create( + serializer = WidgetStateSerializer, + produceFile = { File(context.dataDir, "datastore/$DATA_STORE_FILENAME-$fileKey.json") }, + ) + } + + override fun getLocation(context: Context, fileKey: String): File { + return File(context.dataDir, "datastore/$DATA_STORE_FILENAME-$fileKey.json") + } +} + +private object WidgetStateSerializer : Serializer { + override val defaultValue: WidgetState = WidgetState() + + override suspend fun readFrom(input: InputStream): WidgetState = + runCatching { + Json.decodeFromString(input.readBytes().decodeToString()) + }.getOrDefault(defaultValue) + + override suspend fun writeTo(t: WidgetState, output: OutputStream) { + output.write(Json.encodeToString(WidgetState.serializer(), t).toByteArray()) + } +} +``` + +- [ ] **Step 2: Wire stateDefinition into each widget class** + +In `BadgeWidget.kt`, `DashboardWidget.kt`, and `WarRoomWidget.kt`, add to each `GlanceAppWidget` class body: + +```kotlin +override val stateDefinition = WidgetStateDefinition +``` + +For example, `BadgeWidget` becomes: + +```kotlin +class BadgeWidget : GlanceAppWidget() { + override val stateDefinition = WidgetStateDefinition + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val state = currentState() + provideContent { BadgeWidgetContent(state) } + } +} +``` + +- [ ] **Step 3: Update WidgetUpdateWorker to write state before triggering update** + +In `WidgetUpdateWorker.doWork()`, before calling `updateAll()`, update the Glance state: + +```kotlin +override suspend fun doWork(): Result { + return withContext(Dispatchers.IO) { + runCatching { + // Get history and settings from ObjectBox / SharedPreferences + // (adapt to actual app data access pattern) + val history = emptyList() // TODO: load from ObjectBox + val weeklyGoal = 4 // TODO: load from settings + val repo = WidgetDataRepository(applicationContext, getBoxStore(applicationContext)) + val state = repo.buildWidgetState(history, weeklyGoal) + + // Write state to all widget instances + val glanceIds = GlanceAppWidgetManager(applicationContext).getGlanceIds(BadgeWidget::class.java) + glanceIds.forEach { id -> + updateAppWidgetState(applicationContext, WidgetStateDefinition, id) { state } + } + // Repeat for DashboardWidget and WarRoomWidget + val dashIds = GlanceAppWidgetManager(applicationContext).getGlanceIds(DashboardWidget::class.java) + dashIds.forEach { id -> + updateAppWidgetState(applicationContext, WidgetStateDefinition, id) { state } + } + val warIds = GlanceAppWidgetManager(applicationContext).getGlanceIds(WarRoomWidget::class.java) + warIds.forEach { id -> + updateAppWidgetState(applicationContext, WidgetStateDefinition, id) { state } + } + + BadgeWidget().updateAll(applicationContext) + DashboardWidget().updateAll(applicationContext) + WarRoomWidget().updateAll(applicationContext) + }.fold( + onSuccess = { Result.success() }, + onFailure = { Result.retry() }, + ) + } +} +``` + +Add imports: +```kotlin +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState +``` + +> The `getBoxStore(applicationContext)` call must use however the app's ObjectBox store is accessed globally — check the Application class or dependency injection setup. + +- [ ] **Step 4: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 5: Commit** + +``` +git add app/src/main/java/com/ironlog/app/widget/WidgetStateDefinition.kt +git add app/src/main/java/com/ironlog/app/widget/BadgeWidget.kt +git add app/src/main/java/com/ironlog/app/widget/DashboardWidget.kt +git add app/src/main/java/com/ironlog/app/widget/WarRoomWidget.kt +git add app/src/main/java/com/ironlog/app/widget/WidgetUpdateWorker.kt +git commit -m "feat: wire Glance DataStore state definition into all three widgets" +``` + +--- + +### Task 10: Run full unit test suite + +- [ ] **Step 1: Run all tests** + +``` +.\gradlew :app:testDebugUnitTest +``` + +Expected: `BUILD SUCCESSFUL`, no regressions. + +- [ ] **Step 2: If any tests fail, fix them** + +Read the failure output, fix the specific failing test or the code it tests, re-run. + +- [ ] **Step 3: Final commit** + +``` +git add -A +git commit -m "test: all widget unit tests pass — WidgetStateTest and WidgetDataRepository verified" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ Glance dependency added (`glance-appwidget:1.1.1`, `glance-material3:1.1.1`) +- ✅ 3 widget sizes: 2×2 Badge, 4×2 Dashboard, 4×4 War Room +- ✅ All 3 have `GlanceAppWidget` + `GlanceAppWidgetReceiver` +- ✅ All 3 registered in AndroidManifest with `appwidget-provider` XML +- ✅ `WidgetState` — serializable, has `xpPercent` and `weekPercent` computed properties +- ✅ `WidgetStateDefinition` — custom Glance DataStore serializer +- ✅ `WidgetDataRepository` — reads ObjectBox data, assembles WidgetState +- ✅ `WidgetUpdateWorker` — 30-min periodic + on-demand `enqueueOneTime()` +- ✅ WorkManager periodic enqueue in Application.onCreate() +- ✅ Unit tests for `WidgetState` computed properties +- ✅ Dark theme aesthetic (Solo Leveling inspired: navy/black bg, orange/red accents, rank colors) +- ✅ Week progress as colored dots (matches Solo Leveling dungeon gate aesthetic) + +**Known implementation notes:** +- Glance `LinearProgressIndicator` is available in `glance-appwidget` — if it's not in 1.1.1, use a `Box` with a colored inner `Box` sized by `fillMaxWidth(fraction)` instead. +- `WidgetDataRepository.buildWidgetState()` has a TODO for plan entity name — must be resolved before the worker can populate the recommended day name. +- `getBoxStore(applicationContext)` in WidgetUpdateWorker — use however the app's ObjectBox `BoxStore` singleton is accessed (often via an Application-level property). diff --git a/docs/superpowers/plans/2026-05-18-onboarding.md b/docs/superpowers/plans/2026-05-18-onboarding.md new file mode 100644 index 0000000..da23ec9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-onboarding.md @@ -0,0 +1,2473 @@ +# Onboarding — Implementation Plan + +> **Status:** Historical execution plan. Its checkboxes were not backfilled and are not a current backlog. Use `AGENTS.md` and the current source/tests as the authoritative project state. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the existing 6-slide static onboarding with a cinematic 8-screen "SYSTEM AWAKENING" flow that collects userName, weeklyGoalDays, weightUnit, goalMode, progressionStyle, cloud AI API key/model, and all three Android permissions, saving everything atomically on final screen. + +**Architecture:** All copy/config lives in `OnboardingConfig.kt`; transient state lives in `OnboardingViewModel`; each screen is a separate composable file in `ui/screens/onboarding/steps/`; `OnboardingScreen.kt` is the `HorizontalPager` shell; `AppNavigator.kt` wires the ViewModel. + +**Tech Stack:** Kotlin 2.x, Jetpack Compose, Material3, HorizontalPager, EncryptedSharedPreferences (security-crypto already in deps), Health Connect API, ObjectBox (no changes), `rememberInfiniteTransition` for animations + +--- + +## File Map + +| Status | Path | Purpose | +|--------|------|---------| +| **Create** | `ui/screens/onboarding/OnboardingConfig.kt` | All copy, colors, model options — single source of truth | +| **Create** | `ui/screens/onboarding/OnboardingViewModel.kt` | `OnboardingDraft` state, `completeOnboarding()` | +| **Create** | `ui/screens/onboarding/OnboardingComponents.kt` | Shared composables: `RankBadge`, `GlowButton`, `ParticleField`, `TypewriterText`, `GlowCard` | +| **Create** | `ui/screens/onboarding/steps/Step1Awakening.kt` | Cinematic splash, auto-advance | +| **Create** | `ui/screens/onboarding/steps/Step2Registration.kt` | Hunter name input | +| **Create** | `ui/screens/onboarding/steps/Step3Classification.kt` | Rank/training level cards | +| **Create** | `ui/screens/onboarding/steps/Step4Quota.kt` | Day picker + unit toggle | +| **Create** | `ui/screens/onboarding/steps/Step5GoalMode.kt` | 2×2 goal mode grid | +| **Create** | `ui/screens/onboarding/steps/Step6AiAbilities.kt` | Intelligence mode + API key entry | +| **Create** | `ui/screens/onboarding/steps/Step7Permissions.kt` | Camera + Health Connect + Notifications | +| **Create** | `ui/screens/onboarding/steps/Step8Arise.kt` | Cinematic finale + ARISE button | +| **Replace** | `ui/screens/OnboardingScreen.kt` | Shell: `HorizontalPager` over 8 steps, wires ViewModel | +| **Modify** | `navigation/AppNavigator.kt` | Pass `OnboardingViewModel` to `OnboardingScreen`, update `settingsRepoSaveOnboardingData` | +| **Create** | `domain/badges/BadgeDefinitions.kt` | 16 badge definitions (referenced by gamification plan) | +| **Create** | `res/values/strings_onboarding.xml` | All string resources | +| **Create** | `test/.../onboarding/OnboardingViewModelTest.kt` | ViewModel unit tests | +| **Create** | `test/.../onboarding/OnboardingConfigTest.kt` | Config sanity tests | + +--- + +## Task 1: OnboardingConfig + String Resources + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingConfig.kt` +- Create: `app/src/main/res/values/strings_onboarding.xml` + +- [ ] **Step 1: Create `strings_onboarding.xml`** + +```xml + + + + THE SYSTEM HAS DETECTED A NEW HUNTER + INITIALIZING REGISTRATION PROTOCOL… + + + HUNTER DESIGNATION + Enter your designation, Hunter + Your name + CONFIRM IDENTITY + + + ASSESS YOUR CURRENT RANK + The System will calibrate your training path + + + SET YOUR WEEKLY OBJECTIVE + %d SESSIONS PER WEEK + SET OBJECTIVE + + + CHOOSE YOUR COMBAT STYLE + LOCK IN + + + ENHANCE YOUR HUNTER ABILITIES + Unlock AI-powered coaching and adaptive recommendations + ON-DEVICE INTELLIGENCE — ACTIVE + Recovery scoring, workout suggestion, effort tracking + TAP TO UNLOCK CLOUD AI COACHING + CLOUD AI READY ✓ + Paste your API key here + Stored locally in encrypted storage, never uploaded + ✦ GEMINI 2.0 FLASH — RECOMMENDED (FREE)\n1,500 requests/day · 15 req/min · 1M token context + Get free key at aistudio.google.com → + ACTIVATE + Skip for now + + + UNLOCK HUNTER ABILITIES + Each permission activates a System feature. All optional. + SCANNER — QR SHARING + Scan & share workout plans as QR codes + VITALS — HEALTH CONNECT + Auto-import sleep, HRV, heart rate for recovery scores + ALERTS — NOTIFICATIONS + Rest timer, streak reminders, daily check-ins + GRANT + GRANTED ✓ + DENIED + Grant later in Settings + CONTINUE + + + REGISTRATION COMPLETE + THE SYSTEM ACKNOWLEDGES YOUR EXISTENCE. + YOUR JOURNEY BEGINS NOW. + ARISE + + + Add IronLog to Your Home Screen + Quick-start workouts and check your streak without opening the app. + ADD WIDGET + Maybe Later + +``` + +- [ ] **Step 2: Create `OnboardingConfig.kt`** + +```kotlin +package com.ironlog.app.ui.screens.onboarding + +import androidx.compose.ui.graphics.Color + +object OnboardingConfig { + + // ── Colors ──────────────────────────────────────────────────────────────── + val accentBlue = Color(0xFF4FC3F7) + val accentGold = Color(0xFFFFD700) + val bgDark = Color(0xFF050508) + val surfaceDark = Color(0xFF0D0D14) + val cardBorder = Color(0xFF1A1A2E) + val textMuted = Color(0xFF8888AA) + val grantedColor = Color(0xFF26A69A) + val deniedColor = Color(0xFFEF5350) + + // ── AI provider / model options ─────────────────────────────────────────── + enum class AiProvider(val displayName: String) { + GEMINI("Google Gemini"), + OPENAI("OpenAI"), + CUSTOM("Custom"), + } + + data class AiModelOption( + val modelId: String, + val displayName: String, + val subtitle: String, + ) + + val geminiModels = listOf( + AiModelOption("gemini-2.0-flash", "Gemini 2.0 Flash", "Free · 1,500 req/day · 1M ctx"), + AiModelOption("gemini-2.5-flash", "Gemini 2.5 Flash", "Free · 250 req/day · 250K ctx"), + AiModelOption("gemini-2.5-pro", "Gemini 2.5 Pro", "Free · 100 req/day · 1M ctx"), + ) + + val openAiModels = listOf( + AiModelOption("gpt-4o-mini", "GPT-4o Mini", "Fast · cost-efficient"), + AiModelOption("gpt-4o", "GPT-4o", "Most capable"), + ) + + fun modelsFor(provider: AiProvider): List = when (provider) { + AiProvider.GEMINI -> geminiModels + AiProvider.OPENAI -> openAiModels + AiProvider.CUSTOM -> emptyList() + } + + fun defaultModelFor(provider: AiProvider): String = when (provider) { + AiProvider.GEMINI -> "gemini-2.0-flash" + AiProvider.OPENAI -> "gpt-4o-mini" + AiProvider.CUSTOM -> "" + } + + /** Returns true if the key looks structurally valid for the given provider. */ + fun isKeyFormatValid(provider: AiProvider, key: String): Boolean = when (provider) { + AiProvider.GEMINI -> key.startsWith("AIza") && key.length > 20 + AiProvider.OPENAI -> key.startsWith("sk-") && key.length > 20 + AiProvider.CUSTOM -> key.isNotBlank() + } + + const val AI_STUDIO_URL = "https://aistudio.google.com/app/apikey" + + // ── Rank classification ─────────────────────────────────────────────────── + data class RankOption( + val rankLetter: String, + val label: String, + val description: String, + val progressionStyle: String, // matches ProgressionStyle enum name + val defaultGoalMode: String, // matches GoalMode enum name + val ringColor: Color, + ) + + val rankOptions = listOf( + RankOption("E", "NOVICE", "Starting my journey", "LINEAR", "STRENGTH", Color(0xFF888888)), + RankOption("C", "INTERMEDIATE", "Training 6+ months", "DOUBLE_PROGRESSION","HYPERTROPHY", Color(0xFFAAAAAA)), + RankOption("A", "ADVANCED", "2+ years, serious lifter", "UNDULATING", "PERFORMANCE", accentGold), + ) + + // ── Goal mode options ───────────────────────────────────────────────────── + data class GoalModeOption( + val goalMode: String, // matches GoalMode enum name + val label: String, + val subtitle: String, + val iconDrawableRes: Int, // e.g. R.drawable.ic_strength + ) + // iconDrawableRes values are filled in by the implementer using existing drawables + // (or newly added ones) — do not hardcode R.drawable references here as they + // change at compile time. Pass them in from the composable via GoalModeOption. + + // ── Permission order ────────────────────────────────────────────────────── + enum class OnboardingPermission { CAMERA, HEALTH_CONNECT, NOTIFICATIONS } + + val permissionOrder = listOf( + OnboardingPermission.CAMERA, + OnboardingPermission.HEALTH_CONNECT, + OnboardingPermission.NOTIFICATIONS, + ) + + // ── Particle config ─────────────────────────────────────────────────────── + const val PARTICLE_COUNT_DRIFT = 50 + const val PARTICLE_COUNT_BURST = 200 + const val TYPEWRITER_DELAY_MS = 40L + const val SCREEN1_AUTO_ADVANCE_MS = 2500L +} +``` + +- [ ] **Step 3: Verify file compiles (no Android Studio needed — just check for syntax errors by reviewing imports)** + +All types used (`Color`) are available via `androidx.compose.ui.graphics.Color`. No runtime deps. + +- [ ] **Step 4: Commit** + +```powershell +cd "Z:\KOTLIN\UnifiedPort" +git add app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingConfig.kt +git add app/src/main/res/values/strings_onboarding.xml +git commit -m "feat(onboarding): add OnboardingConfig and string resources" +``` + +--- + +## Task 2: OnboardingViewModel + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingViewModel.kt` +- Create: `app/src/test/java/com/ironlog/app/ui/screens/onboarding/OnboardingViewModelTest.kt` + +The ViewModel holds `OnboardingDraft` state and calls `settingsRepoSaveOnboardingData` on completion. + +- [ ] **Step 1: Write the failing test** + +```kotlin +// app/src/test/java/com/ironlog/app/ui/screens/onboarding/OnboardingViewModelTest.kt +package com.ironlog.app.ui.screens.onboarding + +import org.junit.Assert.* +import org.junit.Test + +class OnboardingViewModelTest { + + @Test fun `initial draft has defaults`() { + val vm = OnboardingViewModel() + assertEquals("", vm.draft.value.userName) + assertEquals(3, vm.draft.value.weeklyGoalDays) + assertEquals("kg", vm.draft.value.weightUnit) + assertEquals("LOCAL", vm.draft.value.intelligenceMode) + assertFalse(vm.draft.value.cameraGranted) + assertFalse(vm.draft.value.healthConnectGranted) + assertFalse(vm.draft.value.notificationsGranted) + } + + @Test fun `updateUserName trims and updates`() { + val vm = OnboardingViewModel() + vm.updateUserName(" Hunter ") + assertEquals("Hunter", vm.draft.value.userName) + } + + @Test fun `updateWeeklyGoalDays clamps to 1-7`() { + val vm = OnboardingViewModel() + vm.updateWeeklyGoalDays(0) + assertEquals(1, vm.draft.value.weeklyGoalDays) + vm.updateWeeklyGoalDays(8) + assertEquals(7, vm.draft.value.weeklyGoalDays) + vm.updateWeeklyGoalDays(5) + assertEquals(5, vm.draft.value.weeklyGoalDays) + } + + @Test fun `setting a valid API key sets intelligenceMode to AUTO`() { + val vm = OnboardingViewModel() + vm.updateCloudApiKey("AIzaFakeKeyForTest1234567890") + assertEquals("AUTO", vm.draft.value.intelligenceMode) + } + + @Test fun `clearing API key reverts intelligenceMode to LOCAL`() { + val vm = OnboardingViewModel() + vm.updateCloudApiKey("AIzaFakeKeyForTest1234567890") + vm.updateCloudApiKey("") + assertEquals("LOCAL", vm.draft.value.intelligenceMode) + } + + @Test fun `setClassification seeds goalMode default`() { + val vm = OnboardingViewModel() + vm.setClassification(progressionStyle = "UNDULATING", defaultGoalMode = "PERFORMANCE") + assertEquals("UNDULATING", vm.draft.value.progressionStyle) + assertEquals("PERFORMANCE", vm.draft.value.goalMode) + } + + @Test fun `setGoalMode overrides seeded default`() { + val vm = OnboardingViewModel() + vm.setClassification(progressionStyle = "LINEAR", defaultGoalMode = "STRENGTH") + vm.setGoalMode("HYPERTROPHY") + assertEquals("HYPERTROPHY", vm.draft.value.goalMode) + } + + @Test fun `permission flags update independently`() { + val vm = OnboardingViewModel() + vm.setCameraGranted(true) + assertTrue(vm.draft.value.cameraGranted) + assertFalse(vm.draft.value.healthConnectGranted) + vm.setHealthConnectGranted(true) + assertTrue(vm.draft.value.healthConnectGranted) + assertTrue(vm.draft.value.cameraGranted) + } +} +``` + +- [ ] **Step 2: Run test (expect failure — class doesn't exist yet)** + +```powershell +cd "Z:\KOTLIN\UnifiedPort" +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.ui.screens.onboarding.OnboardingViewModelTest" 2>&1 | Select-String -Pattern "FAILED|ERROR|error:" | Select-Object -First 20 +``` + +Expected: compile error — `OnboardingViewModel` not found. + +- [ ] **Step 3: Create `OnboardingViewModel.kt`** + +```kotlin +package com.ironlog.app.ui.screens.onboarding + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class OnboardingDraft( + val userName: String = "", + val progressionStyle: String = "LINEAR", + val goalMode: String = "STRENGTH", + val weeklyGoalDays: Int = 3, + val weightUnit: String = "kg", + val cloudAiApiKey: String = "", + val cloudAiModelName: String = "gemini-2.0-flash", + val cloudAiProviderPreset: String = "GEMINI", + val intelligenceMode: String = "LOCAL", + val cameraGranted: Boolean = false, + val healthConnectGranted: Boolean = false, + val notificationsGranted: Boolean = false, +) + +class OnboardingViewModel : ViewModel() { + + private val _draft = MutableStateFlow(OnboardingDraft()) + val draft: StateFlow = _draft.asStateFlow() + + fun updateUserName(name: String) { + _draft.update { it.copy(userName = name.trim()) } + } + + fun updateWeeklyGoalDays(days: Int) { + _draft.update { it.copy(weeklyGoalDays = days.coerceIn(1, 7)) } + } + + fun updateWeightUnit(unit: String) { + _draft.update { it.copy(weightUnit = unit) } + } + + fun setClassification(progressionStyle: String, defaultGoalMode: String) { + _draft.update { it.copy(progressionStyle = progressionStyle, goalMode = defaultGoalMode) } + } + + fun setGoalMode(mode: String) { + _draft.update { it.copy(goalMode = mode) } + } + + fun updateCloudProvider(provider: String, defaultModel: String) { + _draft.update { it.copy(cloudAiProviderPreset = provider, cloudAiModelName = defaultModel) } + } + + fun updateCloudApiKey(key: String) { + _draft.update { + it.copy( + cloudAiApiKey = key, + intelligenceMode = if (key.isNotBlank()) "AUTO" else "LOCAL", + ) + } + } + + fun updateCloudModelName(model: String) { + _draft.update { it.copy(cloudAiModelName = model) } + } + + fun setCameraGranted(granted: Boolean) { + _draft.update { it.copy(cameraGranted = granted) } + } + + fun setHealthConnectGranted(granted: Boolean) { + _draft.update { it.copy(healthConnectGranted = granted) } + } + + fun setNotificationsGranted(granted: Boolean) { + _draft.update { it.copy(notificationsGranted = granted) } + } +} +``` + +- [ ] **Step 4: Run tests (expect pass)** + +```powershell +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.ui.screens.onboarding.OnboardingViewModelTest" +``` + +Expected: `BUILD SUCCESSFUL`, 8 tests passed. + +- [ ] **Step 5: Commit** + +```powershell +git add app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingViewModel.kt +git add app/src/test/java/com/ironlog/app/ui/screens/onboarding/OnboardingViewModelTest.kt +git commit -m "feat(onboarding): add OnboardingViewModel with full draft state management" +``` + +--- + +## Task 3: Shared Composable Components + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingComponents.kt` + +All reusable UI building blocks used across multiple step screens. + +- [ ] **Step 1: Create `OnboardingComponents.kt`** + +```kotlin +package com.ironlog.app.ui.screens.onboarding + +import androidx.compose.animation.core.* +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import kotlin.math.cos +import kotlin.math.sin +import kotlin.random.Random + +// ── Particle data ───────────────────────────────────────────────────────────── + +private data class DriftParticle(val x: Float, val y: Float, val radius: Float, val speedFactor: Float, val alpha: Float) + +/** + * Ambient particle drift field — 50 white/blue dots moving upward infinitely. + * Used on Screens 1 and 8. + */ +@Composable +fun ParticleField(modifier: Modifier = Modifier, count: Int = OnboardingConfig.PARTICLE_COUNT_DRIFT) { + val particles = remember { + List(count) { + DriftParticle( + x = Random.nextFloat(), + y = Random.nextFloat(), + radius = Random.nextFloat() * 2f + 1f, + speedFactor = Random.nextFloat() * 0.5f + 0.3f, + alpha = Random.nextFloat() * 0.5f + 0.2f, + ) + } + } + val infiniteTransition = rememberInfiniteTransition(label = "particles") + val progress by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(8000, easing = LinearEasing)), + label = "particleProgress", + ) + Canvas(modifier = modifier.fillMaxSize()) { + particles.forEach { p -> + val yPos = (p.y - progress * p.speedFactor).mod(1f) + drawCircle( + color = OnboardingConfig.accentBlue.copy(alpha = p.alpha), + radius = p.radius, + center = Offset(p.x * size.width, yPos * size.height), + ) + } + } +} + +// ── Typewriter text ─────────────────────────────────────────────────────────── + +/** + * Animates [text] character-by-character with [delayMs] between each character. + * Calls [onComplete] when the full string has been revealed. + */ +@Composable +fun TypewriterText( + text: String, + modifier: Modifier = Modifier, + color: Color = OnboardingConfig.accentBlue, + fontSize: Int = 14, + delayMs: Long = OnboardingConfig.TYPEWRITER_DELAY_MS, + onComplete: () -> Unit = {}, +) { + var displayed by remember(text) { mutableStateOf("") } + LaunchedEffect(text) { + displayed = "" + text.forEach { char -> + delay(delayMs) + displayed += char + } + onComplete() + } + Text( + text = displayed, + color = color, + fontSize = fontSize.sp, + modifier = modifier, + textAlign = TextAlign.Center, + ) +} + +// ── Rank badge ──────────────────────────────────────────────────────────────── + +/** + * Metallic ring badge with a rank letter inside. + * [ringColor] is the metallic rim color; defaults to silver. + */ +@Composable +fun RankBadge( + rank: String, + ringColor: Color, + size: Dp = 80.dp, + glowAlpha: Float = 0.6f, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(size) + .clip(CircleShape) + .background(OnboardingConfig.bgDark) + .border( + width = 3.dp, + brush = Brush.radialGradient(listOf(ringColor, ringColor.copy(alpha = 0.3f))), + shape = CircleShape, + ), + ) { + // Outer glow ring + Canvas(modifier = Modifier.fillMaxSize()) { + drawCircle( + color = ringColor.copy(alpha = glowAlpha * 0.3f), + radius = size.toPx() / 2f + 6f, + ) + } + Text( + text = rank, + color = ringColor, + fontSize = (size.value * 0.38f).sp, + fontWeight = FontWeight.Black, + ) + } +} + +// ── Glow button ─────────────────────────────────────────────────────────────── + +/** + * Primary CTA button with electric-blue gradient and shadow glow. + */ +@Composable +fun GlowButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + color: Color = OnboardingConfig.accentBlue, +) { + val alpha by animateFloatAsState(if (enabled) 1f else 0.4f, label = "btnAlpha") + Button( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors(containerColor = color.copy(alpha = alpha)), + modifier = modifier + .fillMaxWidth() + .height(52.dp), + ) { + Text( + text = text, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + letterSpacing = 2.sp, + color = Color.White.copy(alpha = alpha), + ) + } +} + +// ── Glow card ───────────────────────────────────────────────────────────────── + +/** + * Dark card with animated border glow. [selected] drives the glow intensity. + */ +@Composable +fun GlowCard( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + glowColor: Color = OnboardingConfig.accentBlue, + content: @Composable ColumnScope.() -> Unit, +) { + val borderColor by animateColorAsState( + targetValue = if (selected) glowColor else OnboardingConfig.cardBorder, + animationSpec = tween(300), + label = "cardBorder", + ) + val bgAlpha by animateFloatAsState(if (selected) 0.08f else 0f, label = "cardBg") + Column( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(glowColor.copy(alpha = bgAlpha)) + .border(1.5.dp, borderColor, RoundedCornerShape(12.dp)) + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp), + content = content, + ) +} +``` + +- [ ] **Step 2: Verify imports resolve** + +Review that all imports exist in the project's Compose dependencies. `animateColorAsState` and `animateFloatAsState` are in `androidx.compose.animation.core`. + +- [ ] **Step 3: Commit** + +```powershell +git add app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingComponents.kt +git commit -m "feat(onboarding): add shared composable components (ParticleField, TypewriterText, RankBadge, GlowButton, GlowCard)" +``` + +--- + +## Task 4: Step 1 — SYSTEM AWAKENING + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step1Awakening.kt` + +- [ ] **Step 1: Create `Step1Awakening.kt`** + +```kotlin +package com.ironlog.app.ui.screens.onboarding.steps + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.R +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig +import com.ironlog.app.ui.screens.onboarding.OnboardingComponents +import com.ironlog.app.ui.screens.onboarding.ParticleField +import com.ironlog.app.ui.screens.onboarding.TypewriterText +import kotlinx.coroutines.delay + +/** + * Screen 1 — Cinematic SYSTEM AWAKENING splash. + * Auto-advances after [OnboardingConfig.SCREEN1_AUTO_ADVANCE_MS]. + */ +@Composable +fun Step1Awakening(onAdvance: () -> Unit) { + val logoAlpha by animateFloatAsState( + targetValue = 1f, + animationSpec = tween(1200, easing = FastOutSlowInEasing), + label = "logoAlpha", + ) + + var showLine1 by remember { mutableStateOf(false) } + var showLine2 by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + delay(600) + showLine1 = true + } + + LaunchedEffect(Unit) { + delay(OnboardingConfig.SCREEN1_AUTO_ADVANCE_MS) + onAdvance() + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark), + ) { + ParticleField() + + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Logo placeholder — replace with actual IronLogLogo composable + Text( + text = "IRONLOG", + color = OnboardingConfig.accentBlue, + fontSize = 36.sp, + fontWeight = FontWeight.Black, + letterSpacing = 8.sp, + modifier = Modifier.alpha(logoAlpha), + ) + + Spacer(Modifier.height(48.dp)) + + if (showLine1) { + TypewriterText( + text = stringResource(R.string.onb_awakening_line1), + fontSize = 12, + modifier = Modifier.padding(horizontal = 32.dp), + onComplete = { showLine2 = true }, + ) + } + + if (showLine2) { + Spacer(Modifier.height(8.dp)) + TypewriterText( + text = stringResource(R.string.onb_awakening_line2), + fontSize = 12, + color = OnboardingConfig.textMuted, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } + } + } +} +``` + +- [ ] **Step 2: Commit** + +```powershell +git add app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step1Awakening.kt +git commit -m "feat(onboarding): add Step1 SYSTEM AWAKENING cinematic screen" +``` + +--- + +## Task 5: Step 2 — HUNTER REGISTRATION + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step2Registration.kt` + +- [ ] **Step 1: Create `Step2Registration.kt`** + +```kotlin +package com.ironlog.app.ui.screens.onboarding.steps + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.R +import com.ironlog.app.ui.screens.onboarding.GlowButton +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig + +/** + * Screen 2 — Hunter name input. + */ +@Composable +fun Step2Registration( + userName: String, + onUserNameChange: (String) -> Unit, + onNext: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + Box( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark) + .padding(horizontal = 32.dp), + ) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.onb_reg_header), + color = OnboardingConfig.accentBlue, + fontSize = 22.sp, + fontWeight = FontWeight.Black, + letterSpacing = 4.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.onb_reg_subtext), + color = OnboardingConfig.textMuted, + fontSize = 14.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(40.dp)) + + OutlinedTextField( + value = userName, + onValueChange = { if (it.length <= 30) onUserNameChange(it) }, + placeholder = { Text(stringResource(R.string.onb_reg_hint), color = OnboardingConfig.textMuted) }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = OnboardingConfig.accentBlue, + unfocusedBorderColor = OnboardingConfig.accentBlue.copy(alpha = 0.3f), + cursorColor = OnboardingConfig.accentBlue, + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + ), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { if (userName.isNotBlank()) onNext() }), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) + + Spacer(Modifier.height(40.dp)) + + GlowButton( + text = stringResource(R.string.onb_reg_cta), + onClick = onNext, + enabled = userName.isNotBlank(), + ) + } + } +} +``` + +- [ ] **Step 2: Commit** + +```powershell +git add app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step2Registration.kt +git commit -m "feat(onboarding): add Step2 HUNTER REGISTRATION name input screen" +``` + +--- + +## Task 6: Step 3 — TRAINING CLASSIFICATION + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step3Classification.kt` + +- [ ] **Step 1: Create `Step3Classification.kt`** + +```kotlin +package com.ironlog.app.ui.screens.onboarding.steps + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.R +import com.ironlog.app.ui.screens.onboarding.GlowCard +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig +import com.ironlog.app.ui.screens.onboarding.RankBadge + +/** + * Screen 3 — Rank/training level selection. + * Auto-advances on card tap. + */ +@Composable +fun Step3Classification( + selectedProgressionStyle: String, + onSelect: (progressionStyle: String, defaultGoalMode: String) -> Unit, + onNext: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.onb_class_header), + color = OnboardingConfig.accentBlue, + fontSize = 20.sp, + fontWeight = FontWeight.Black, + letterSpacing = 3.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.onb_class_subtext), + color = OnboardingConfig.textMuted, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(40.dp)) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 8.dp), + ) { + items(OnboardingConfig.rankOptions) { option -> + val isSelected = option.progressionStyle == selectedProgressionStyle + GlowCard( + selected = isSelected, + glowColor = option.ringColor, + onClick = { + onSelect(option.progressionStyle, option.defaultGoalMode) + onNext() + }, + modifier = Modifier.width(160.dp), + ) { + RankBadge( + rank = option.rankLetter, + ringColor = option.ringColor, + size = 64.dp, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = option.label, + color = if (isSelected) option.ringColor else OnboardingConfig.textMuted, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 2.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(4.dp)) + Text( + text = option.description, + color = OnboardingConfig.textMuted, + fontSize = 11.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} +``` + +- [ ] **Step 2: Commit** + +```powershell +git add app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step3Classification.kt +git commit -m "feat(onboarding): add Step3 TRAINING CLASSIFICATION rank selection" +``` + +--- + +## Task 7: Step 4 — WEEKLY MISSION QUOTA + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step4Quota.kt` + +- [ ] **Step 1: Create `Step4Quota.kt`** + +```kotlin +package com.ironlog.app.ui.screens.onboarding.steps + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.R +import com.ironlog.app.ui.screens.onboarding.GlowButton +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig + +private val DAY_LABELS = listOf("M", "T", "W", "T", "F", "S", "S") + +/** + * Screen 4 — Weekly session count (dot picker) + kg/lbs toggle. + * + * [selectedDayIndices] is a set of 0-based indices (0=Mon … 6=Sun). + * [weeklyGoalDays] is derived as selectedDayIndices.size in the ViewModel, + * but shown live here for immediate feedback. + */ +@Composable +fun Step4Quota( + selectedDayIndices: Set, + onDayToggle: (index: Int) -> Unit, + weightUnit: String, + onWeightUnitChange: (String) -> Unit, + onNext: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark) + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.onb_quota_header), + color = OnboardingConfig.accentBlue, + fontSize = 20.sp, + fontWeight = FontWeight.Black, + letterSpacing = 3.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(40.dp)) + + // Day dot picker + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DAY_LABELS.forEachIndexed { index, label -> + val isSelected = index in selectedDayIndices + val bgColor by animateColorAsState( + if (isSelected) OnboardingConfig.accentBlue else Color.Transparent, + tween(200), label = "dot$index" + ) + val textColor by animateColorAsState( + if (isSelected) Color.White else OnboardingConfig.textMuted, + tween(200), label = "dotText$index" + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(bgColor) + .border(1.5.dp, OnboardingConfig.accentBlue.copy(alpha = if (isSelected) 0f else 0.3f), CircleShape) + .clickable { + // Prevent deselecting last dot + if (!isSelected || selectedDayIndices.size > 1) onDayToggle(index) + }, + ) { + Text(text = label, color = textColor, fontSize = 13.sp, fontWeight = FontWeight.Bold) + } + } + } + + Spacer(Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.onb_quota_sessions, selectedDayIndices.size), + color = OnboardingConfig.accentGold, + fontSize = 24.sp, + fontWeight = FontWeight.Black, + letterSpacing = 2.sp, + ) + + Spacer(Modifier.height(32.dp)) + + // kg / lbs toggle + Row( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, OnboardingConfig.accentBlue.copy(alpha = 0.4f), RoundedCornerShape(8.dp)), + ) { + listOf("kg", "lbs").forEach { unit -> + val isActive = unit == weightUnit + val bg by animateColorAsState( + if (isActive) OnboardingConfig.accentBlue else Color.Transparent, tween(200), label = "unit$unit" + ) + val tc by animateColorAsState( + if (isActive) Color.White else OnboardingConfig.textMuted, tween(200), label = "unitText$unit" + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(bg) + .clickable { onWeightUnitChange(unit) } + .padding(horizontal = 24.dp, vertical = 10.dp), + ) { + Text(unit.uppercase(), color = tc, fontWeight = FontWeight.Bold, fontSize = 13.sp) + } + } + } + + Spacer(Modifier.height(48.dp)) + + GlowButton(text = stringResource(R.string.onb_quota_cta), onClick = onNext) + } +} +``` + +Note: The ViewModel needs two extra fields to support the day-index set: + +Add to `OnboardingDraft`: +```kotlin +val selectedDayIndices: Set = setOf(0, 2, 4), // Mon, Wed, Fri default +``` + +Add to `OnboardingViewModel`: +```kotlin +fun toggleDayIndex(index: Int) { + val current = _draft.value.selectedDayIndices + val updated = if (index in current && current.size > 1) current - index else current + index + _draft.update { it.copy(selectedDayIndices = updated, weeklyGoalDays = updated.size) } +} +``` + +- [ ] **Step 2: Add `selectedDayIndices` to `OnboardingDraft` and `toggleDayIndex` to `OnboardingViewModel`** + +Edit `OnboardingViewModel.kt`: + +In `OnboardingDraft`, add: +```kotlin +val selectedDayIndices: Set = setOf(0, 2, 4), +``` + +In `OnboardingViewModel`, add after `updateWeeklyGoalDays`: +```kotlin +fun toggleDayIndex(index: Int) { + val current = _draft.value.selectedDayIndices + val updated = if (index in current && current.size > 1) current - index else current + index + _draft.update { it.copy(selectedDayIndices = updated, weeklyGoalDays = updated.size) } +} +``` + +- [ ] **Step 3: Re-run ViewModel tests (still pass, new field has a default)** + +```powershell +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.ui.screens.onboarding.OnboardingViewModelTest" +``` + +Expected: all 8 tests pass. + +- [ ] **Step 4: Commit** + +```powershell +git add app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step4Quota.kt +git add app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingViewModel.kt +git commit -m "feat(onboarding): add Step4 WEEKLY MISSION QUOTA day picker + unit toggle" +``` + +--- + +## Task 8: Step 5 — GOAL MODE + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step5GoalMode.kt` + +- [ ] **Step 1: Create `Step5GoalMode.kt`** + +```kotlin +package com.ironlog.app.ui.screens.onboarding.steps + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.R +import com.ironlog.app.ui.screens.onboarding.GlowButton +import com.ironlog.app.ui.screens.onboarding.GlowCard +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig + +// Goal mode definitions — edit here to add/remove/rename modes +private data class GoalOption( + val mode: String, + val label: String, + val subtitle: String, + val iconRes: Int, +) + +// Uses Material Icons outlined — replace with custom drawables if desired +private val GOAL_OPTIONS = listOf( + GoalOption("STRENGTH", "STRENGTH", "Max weight, low reps", android.R.drawable.ic_menu_compass), + GoalOption("HYPERTROPHY", "HYPERTROPHY", "Size & muscle growth", android.R.drawable.ic_menu_compass), + GoalOption("PERFORMANCE", "PERFORMANCE", "Speed & power", android.R.drawable.ic_menu_compass), + GoalOption("ENDURANCE", "ENDURANCE", "High volume, stamina", android.R.drawable.ic_menu_compass), +) +// NOTE for implementer: Replace android.R.drawable.ic_menu_compass with the +// project's actual vector drawables (search res/drawable/ for fitness icons). +// The four icons should be distinct — e.g. barbell, muscle, lightning, runner. + +/** + * Screen 5 — 2×2 goal mode grid. + */ +@Composable +fun Step5GoalMode( + selectedGoalMode: String, + onSelect: (String) -> Unit, + onNext: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.onb_goal_header), + color = OnboardingConfig.accentBlue, + fontSize = 20.sp, + fontWeight = FontWeight.Black, + letterSpacing = 3.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(32.dp)) + + // 2×2 grid via two rows + GOAL_OPTIONS.chunked(2).forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + row.forEach { option -> + val isSelected = option.mode == selectedGoalMode + GlowCard( + selected = isSelected, + glowColor = OnboardingConfig.accentGold, + onClick = { onSelect(option.mode) }, + modifier = Modifier.weight(1f), + ) { + Text( + text = option.label, + color = if (isSelected) OnboardingConfig.accentGold else OnboardingConfig.accentBlue, + fontSize = 12.sp, + fontWeight = FontWeight.Black, + letterSpacing = 2.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(4.dp)) + Text( + text = option.subtitle, + color = OnboardingConfig.textMuted, + fontSize = 11.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + Spacer(Modifier.height(12.dp)) + } + + Spacer(Modifier.height(24.dp)) + + GlowButton(text = stringResource(R.string.onb_goal_cta), onClick = onNext) + } +} +``` + +- [ ] **Step 2: Commit** + +```powershell +git add app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step5GoalMode.kt +git commit -m "feat(onboarding): add Step5 GOAL MODE 2x2 selection grid" +``` + +--- + +## Task 9: Step 6 — UNLOCK AI ABILITIES + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step6AiAbilities.kt` + +- [ ] **Step 1: Create `Step6AiAbilities.kt`** + +```kotlin +package com.ironlog.app.ui.screens.onboarding.steps + +import android.content.Intent +import android.net.Uri +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.R +import com.ironlog.app.ui.screens.onboarding.GlowButton +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig + +/** + * Screen 6 — Intelligence mode selector + optional Cloud AI API key entry. + */ +@Composable +fun Step6AiAbilities( + cloudApiKey: String, + cloudModelName: String, + cloudProvider: String, + onApiKeyChange: (String) -> Unit, + onModelChange: (String) -> Unit, + onProviderChange: (provider: String, defaultModel: String) -> Unit, + onNext: () -> Unit, + onSkip: () -> Unit, +) { + val context = LocalContext.current + var cloudExpanded by remember { mutableStateOf(cloudApiKey.isNotBlank()) } + var keyVisible by remember { mutableStateOf(false) } + var providerExpanded by remember { mutableStateOf(false) } + var modelExpanded by remember { mutableStateOf(false) } + + val currentProvider = OnboardingConfig.AiProvider.entries + .firstOrNull { it.name == cloudProvider } ?: OnboardingConfig.AiProvider.GEMINI + val models = OnboardingConfig.modelsFor(currentProvider) + val keyValid = OnboardingConfig.isKeyFormatValid(currentProvider, cloudApiKey) + + Column( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.onb_ai_header), + color = OnboardingConfig.accentBlue, + fontSize = 20.sp, + fontWeight = FontWeight.Black, + letterSpacing = 3.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.onb_ai_subtext), + color = OnboardingConfig.textMuted, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(24.dp)) + + // ── Tier A: On-device (always active) ──────────────────────────────── + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(OnboardingConfig.surfaceDark) + .border(1.dp, OnboardingConfig.cardBorder, RoundedCornerShape(12.dp)) + .padding(16.dp), + ) { + Column { + Text( + text = stringResource(R.string.onb_ai_local_title), + color = OnboardingConfig.accentGold, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.onb_ai_local_sub), + color = OnboardingConfig.textMuted, + fontSize = 12.sp, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + // ── Tier B: Cloud AI (optional expand) ─────────────────────────────── + val cloudBorderColor = when { + cloudExpanded && keyValid -> OnboardingConfig.accentBlue + cloudExpanded -> OnboardingConfig.accentBlue.copy(alpha = 0.4f) + else -> OnboardingConfig.cardBorder + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(OnboardingConfig.surfaceDark) + .border(1.5.dp, cloudBorderColor, RoundedCornerShape(12.dp)) + .clickable(enabled = !cloudExpanded) { cloudExpanded = true } + .padding(16.dp), + ) { + AnimatedContent(targetState = cloudExpanded, label = "cloudExpand") { expanded -> + if (!expanded) { + Text( + text = stringResource(R.string.onb_ai_cloud_locked), + color = OnboardingConfig.accentBlue, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + } else { + Column { + // Provider row + Text("API PROVIDER", color = OnboardingConfig.textMuted, fontSize = 11.sp, letterSpacing = 1.sp) + Spacer(Modifier.height(4.dp)) + ExposedDropdownMenuBox( + expanded = providerExpanded, + onExpandedChange = { providerExpanded = it }, + ) { + OutlinedTextField( + value = currentProvider.displayName, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(providerExpanded) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = OnboardingConfig.accentBlue, + unfocusedBorderColor = OnboardingConfig.cardBorder, + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + ), + modifier = Modifier.menuAnchor().fillMaxWidth(), + ) + ExposedDropdownMenu( + expanded = providerExpanded, + onDismissRequest = { providerExpanded = false }, + ) { + OnboardingConfig.AiProvider.entries.forEach { p -> + DropdownMenuItem( + text = { Text(p.displayName) }, + onClick = { + onProviderChange(p.name, OnboardingConfig.defaultModelFor(p)) + providerExpanded = false + }, + ) + } + } + } + + Spacer(Modifier.height(12.dp)) + + // API key field + Text("API KEY", color = OnboardingConfig.textMuted, fontSize = 11.sp, letterSpacing = 1.sp) + Spacer(Modifier.height(4.dp)) + OutlinedTextField( + value = cloudApiKey, + onValueChange = onApiKeyChange, + placeholder = { Text(stringResource(R.string.onb_ai_key_hint), color = OnboardingConfig.textMuted, fontSize = 12.sp) }, + visualTransformation = if (keyVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { keyVisible = !keyVisible }) { + Icon( + imageVector = if (keyVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = null, + tint = OnboardingConfig.textMuted, + ) + } + }, + isError = cloudApiKey.isNotBlank() && !keyValid, + supportingText = { + if (cloudApiKey.isNotBlank() && !keyValid) + Text("Invalid key format", color = MaterialTheme.colorScheme.error, fontSize = 11.sp) + else + Text(stringResource(R.string.onb_ai_key_helper), color = OnboardingConfig.textMuted, fontSize = 11.sp) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = OnboardingConfig.accentBlue, + unfocusedBorderColor = OnboardingConfig.cardBorder, + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + ), + modifier = Modifier.fillMaxWidth(), + ) + + // Gemini recommendation card + if (currentProvider == OnboardingConfig.AiProvider.GEMINI) { + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.onb_ai_gemini_badge), + color = OnboardingConfig.accentGold, + fontSize = 12.sp, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.onb_ai_get_key), + color = OnboardingConfig.accentBlue, + fontSize = 12.sp, + modifier = Modifier.clickable { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(OnboardingConfig.AI_STUDIO_URL))) + }, + ) + } + + // Model selector (only when models available) + if (models.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + Text("MODEL", color = OnboardingConfig.textMuted, fontSize = 11.sp, letterSpacing = 1.sp) + Spacer(Modifier.height(4.dp)) + val selectedModel = models.firstOrNull { it.modelId == cloudModelName } ?: models.first() + ExposedDropdownMenuBox( + expanded = modelExpanded, + onExpandedChange = { modelExpanded = it }, + ) { + OutlinedTextField( + value = "${selectedModel.displayName} — ${selectedModel.subtitle}", + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(modelExpanded) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = OnboardingConfig.accentBlue, + unfocusedBorderColor = OnboardingConfig.cardBorder, + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + ), + modifier = Modifier.menuAnchor().fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = modelExpanded, onDismissRequest = { modelExpanded = false }) { + models.forEach { m -> + DropdownMenuItem( + text = { Column { Text(m.displayName, color = Color.White); Text(m.subtitle, color = OnboardingConfig.textMuted, fontSize = 11.sp) } }, + onClick = { onModelChange(m.modelId); modelExpanded = false }, + ) + } + } + } + } + + if (keyValid) { + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.onb_ai_cloud_ready), + color = OnboardingConfig.accentBlue, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + + Spacer(Modifier.height(32.dp)) + + GlowButton(text = stringResource(R.string.onb_ai_cta), onClick = onNext) + Spacer(Modifier.height(12.dp)) + TextButton(onClick = onSkip, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.onb_ai_skip), color = OnboardingConfig.textMuted, fontSize = 13.sp) + } + } +} +``` + +- [ ] **Step 2: Commit** + +```powershell +git add app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step6AiAbilities.kt +git commit -m "feat(onboarding): add Step6 UNLOCK AI ABILITIES with API key entry and model selection" +``` + +--- + +## Task 10: Step 7 — GRANT PERMISSIONS + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step7Permissions.kt` + +- [ ] **Step 1: Create `Step7Permissions.kt`** + +```kotlin +package com.ironlog.app.ui.screens.onboarding.steps + +import android.Manifest +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.health.connect.client.PermissionController +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.* +import com.ironlog.app.R +import com.ironlog.app.ui.screens.onboarding.GlowButton +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig + +private val HC_PERMISSIONS = setOf( + HealthPermission.getReadPermission(SleepSessionRecord::class), + HealthPermission.getReadPermission(RestingHeartRateRecord::class), + HealthPermission.getReadPermission(HeartRateVariabilityRmssdRecord::class), + HealthPermission.getWritePermission(ExerciseSessionRecord::class), +) + +/** + * Screen 7 — Permission grant cards for Camera, Health Connect, and Notifications. + */ +@Composable +fun Step7Permissions( + cameraGranted: Boolean, + healthConnectGranted: Boolean, + notificationsGranted: Boolean, + onCameraGranted: (Boolean) -> Unit, + onHealthConnectGranted: (Boolean) -> Unit, + onNotificationsGranted: (Boolean) -> Unit, + onNext: () -> Unit, +) { + val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { onCameraGranted(it) } + val hcLauncher = rememberLauncherForActivityResult(PermissionController.createRequestPermissionResultContract()) { granted -> + onHealthConnectGranted(HC_PERMISSIONS.all { it in granted }) + } + val notifLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { onNotificationsGranted(it) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.onb_perm_header), + color = OnboardingConfig.accentBlue, + fontSize = 20.sp, + fontWeight = FontWeight.Black, + letterSpacing = 3.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.onb_perm_subtext), + color = OnboardingConfig.textMuted, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(32.dp)) + + PermissionCard( + title = stringResource(R.string.onb_perm_camera_title), + description = stringResource(R.string.onb_perm_camera_desc), + granted = cameraGranted, + onGrant = { cameraLauncher.launch(Manifest.permission.CAMERA) }, + ) + Spacer(Modifier.height(12.dp)) + PermissionCard( + title = stringResource(R.string.onb_perm_health_title), + description = stringResource(R.string.onb_perm_health_desc), + granted = healthConnectGranted, + onGrant = { hcLauncher.launch(HC_PERMISSIONS) }, + ) + Spacer(Modifier.height(12.dp)) + PermissionCard( + title = stringResource(R.string.onb_perm_notif_title), + description = stringResource(R.string.onb_perm_notif_desc), + granted = notificationsGranted, + onGrant = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notifLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + onNotificationsGranted(true) // pre-13: permission not required + } + }, + ) + + Spacer(Modifier.height(40.dp)) + + GlowButton(text = stringResource(R.string.onb_perm_cta), onClick = onNext) + } +} + +@Composable +private fun PermissionCard( + title: String, + description: String, + granted: Boolean, + onGrant: () -> Unit, +) { + val borderColor by animateColorAsState( + if (granted) OnboardingConfig.grantedColor else OnboardingConfig.cardBorder, + tween(300), label = "permBorder$title" + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(OnboardingConfig.surfaceDark) + .border(1.5.dp, borderColor, RoundedCornerShape(12.dp)) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(title, color = OnboardingConfig.accentBlue, fontSize = 13.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp) + Spacer(Modifier.height(2.dp)) + Text(description, color = OnboardingConfig.textMuted, fontSize = 12.sp) + } + Spacer(Modifier.width(12.dp)) + val chipColor by animateColorAsState( + if (granted) OnboardingConfig.grantedColor else Color.Transparent, + tween(300), label = "chipBg$title" + ) + val chipBorder by animateColorAsState( + if (granted) OnboardingConfig.grantedColor else OnboardingConfig.accentBlue, + tween(300), label = "chipBorder$title" + ) + OutlinedButton( + onClick = onGrant, + enabled = !granted, + colors = ButtonDefaults.outlinedButtonColors(containerColor = chipColor), + border = androidx.compose.foundation.BorderStroke(1.dp, chipBorder), + ) { + Text( + text = if (granted) stringResource(R.string.onb_perm_granted) else stringResource(R.string.onb_perm_grant), + color = if (granted) Color.White else OnboardingConfig.accentBlue, + fontSize = 12.sp, + ) + } + } +} +``` + +- [ ] **Step 2: Commit** + +```powershell +git add app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step7Permissions.kt +git commit -m "feat(onboarding): add Step7 GRANT PERMISSIONS (camera, health connect, notifications)" +``` + +--- + +## Task 11: Step 8 — ARISE + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step8Arise.kt` + +- [ ] **Step 1: Create `Step8Arise.kt`** + +```kotlin +package com.ironlog.app.ui.screens.onboarding.steps + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.R +import com.ironlog.app.ui.screens.onboarding.GlowButton +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig +import com.ironlog.app.ui.screens.onboarding.ParticleField +import com.ironlog.app.ui.screens.onboarding.RankBadge + +/** + * Screen 8 — Cinematic ARISE finale. + * [userName] is shown in the "HUNTER [name]" headline. + * [onArise] triggers the final save + navigation. + */ +@Composable +fun Step8Arise( + userName: String, + onArise: () -> Unit, +) { + val infiniteTransition = rememberInfiniteTransition(label = "ariseGlow") + val glowAlpha by infiniteTransition.animateFloat( + initialValue = 0.4f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), + label = "badgeGlow", + ) + + var showContent by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(400) + showContent = true + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(OnboardingConfig.bgDark), + ) { + ParticleField(count = OnboardingConfig.PARTICLE_COUNT_DRIFT) + + if (showContent) { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RankBadge( + rank = "E", + ringColor = OnboardingConfig.accentBlue, + size = 120.dp, + glowAlpha = glowAlpha, + ) + + Spacer(Modifier.height(40.dp)) + + val displayName = userName.ifBlank { "HUNTER" }.uppercase() + Text( + text = "HUNTER $displayName", + color = OnboardingConfig.accentGold, + fontSize = 22.sp, + fontWeight = FontWeight.Black, + letterSpacing = 4.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.onb_arise_reg_complete), + color = OnboardingConfig.accentBlue, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 3.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.onb_arise_line1), + color = OnboardingConfig.textMuted, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.onb_arise_line2), + color = OnboardingConfig.textMuted, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(56.dp)) + + GlowButton( + text = stringResource(R.string.onb_arise_cta), + onClick = onArise, + color = OnboardingConfig.accentBlue, + ) + } + } + } +} +``` + +- [ ] **Step 2: Commit** + +```powershell +git add app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step8Arise.kt +git commit -m "feat(onboarding): add Step8 ARISE cinematic finale screen" +``` + +--- + +## Task 12: OnboardingScreen Shell + AppNavigator Wiring + +**Files:** +- Replace: `app/src/main/java/com/ironlog/app/ui/screens/OnboardingScreen.kt` +- Modify: `app/src/main/java/com/ironlog/app/navigation/AppNavigator.kt` + +- [ ] **Step 1: Replace `OnboardingScreen.kt`** + +The old file had a 6-slide `HorizontalPager` with `OnboardingSlide` data. Replace entirely: + +```kotlin +package com.ironlog.app.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ironlog.app.ui.screens.onboarding.OnboardingConfig +import com.ironlog.app.ui.screens.onboarding.OnboardingViewModel +import com.ironlog.app.ui.screens.onboarding.steps.* +import kotlinx.coroutines.launch + +/** + * Root onboarding composable. + * Forward-only HorizontalPager (swipe disabled). + * [onComplete] is called when the user taps ARISE on Screen 8. + */ +@Composable +fun OnboardingScreen( + onComplete: suspend () -> Unit, + vm: OnboardingViewModel = viewModel(), +) { + val draft by vm.draft.collectAsState() + val pagerState = rememberPagerState(pageCount = { 8 }) + val scope = rememberCoroutineScope() + + fun advance() = scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } + + Box(modifier = Modifier.fillMaxSize().background(OnboardingConfig.bgDark)) { + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + modifier = Modifier.fillMaxSize(), + ) { page -> + when (page) { + 0 -> Step1Awakening(onAdvance = { advance() }) + 1 -> Step2Registration( + userName = draft.userName, + onUserNameChange = vm::updateUserName, + onNext = { advance() }, + ) + 2 -> Step3Classification( + selectedProgressionStyle = draft.progressionStyle, + onSelect = { style, goal -> vm.setClassification(style, goal) }, + onNext = { advance() }, + ) + 3 -> Step4Quota( + selectedDayIndices = draft.selectedDayIndices, + onDayToggle = vm::toggleDayIndex, + weightUnit = draft.weightUnit, + onWeightUnitChange = vm::updateWeightUnit, + onNext = { advance() }, + ) + 4 -> Step5GoalMode( + selectedGoalMode = draft.goalMode, + onSelect = vm::setGoalMode, + onNext = { advance() }, + ) + 5 -> Step6AiAbilities( + cloudApiKey = draft.cloudAiApiKey, + cloudModelName = draft.cloudAiModelName, + cloudProvider = draft.cloudAiProviderPreset, + onApiKeyChange = vm::updateCloudApiKey, + onModelChange = vm::updateCloudModelName, + onProviderChange = { provider, model -> vm.updateCloudProvider(provider, model) }, + onNext = { advance() }, + onSkip = { advance() }, + ) + 6 -> Step7Permissions( + cameraGranted = draft.cameraGranted, + healthConnectGranted = draft.healthConnectGranted, + notificationsGranted = draft.notificationsGranted, + onCameraGranted = vm::setCameraGranted, + onHealthConnectGranted = vm::setHealthConnectGranted, + onNotificationsGranted = vm::setNotificationsGranted, + onNext = { advance() }, + ) + 7 -> Step8Arise( + userName = draft.userName, + onArise = { scope.launch { onComplete() } }, + ) + } + } + } +} +``` + +- [ ] **Step 2: Update `AppNavigator.kt` — update the `OnboardingScreen` call and `settingsRepoSaveOnboardingData`** + +Find the `composable("Onboarding")` block in `AppNavigator.kt`. Replace the existing `OnboardingScreen(...)` call with: + +```kotlin +composable("Onboarding") { + OnboardingScreen( + onComplete = { + val vm = OnboardingViewModel() // or inject via hiltViewModel / remember + // NOTE: In practice, get the VM from the NavBackStackEntry to share it; + // AppNavigator wires this. See step below. + settingsRepoSaveOnboardingData( + goalDays = onboardingGoalDays, + userName = onboardingUserName, + weightUnit = onboardingWeightUnit, + ) + } + ) +} +``` + +Actually — the cleaner approach is to let `OnboardingScreen` own the ViewModel and pass a callback that receives the draft. Update `OnboardingScreen.kt` to expose `onComplete(draft: OnboardingDraft)`: + +```kotlin +// In OnboardingScreen.kt, change signature to: +@Composable +fun OnboardingScreen( + onComplete: suspend (OnboardingDraft) -> Unit, + vm: OnboardingViewModel = viewModel(), +) + +// Step 7 ARISE button: +onArise = { scope.launch { onComplete(draft) } } +``` + +Then in `AppNavigator.kt`, find and replace the `composable("Onboarding")` block: + +```kotlin +composable("Onboarding") { + OnboardingScreen( + onComplete = { draft -> + settingsRepoSaveOnboardingDataFull(draft) + navController.navigate("Tabs") { + popUpTo("Onboarding") { inclusive = true } + } + } + ) +} +``` + +Add `settingsRepoSaveOnboardingDataFull` in `AppNavigator.kt` (replace the old `settingsRepoSaveOnboardingData`): + +```kotlin +private suspend fun settingsRepoSaveOnboardingDataFull(draft: com.ironlog.app.ui.screens.onboarding.OnboardingDraft) { + val repo = SettingsRepository() + repo.setBoolean("onboarding_complete", true) + val raw = repo.getString("ironlog_settings") ?: "{}" + val json = runCatching { org.json.JSONObject(raw) }.getOrDefault(org.json.JSONObject()) + json.put("weeklyGoalDays", draft.weeklyGoalDays.coerceIn(1, 7)) + json.put("weightUnit", if (draft.weightUnit == "lbs") "lbs" else "kg") + json.put("progressionStyle", draft.progressionStyle) + json.put("goalMode", draft.goalMode) + json.put("intelligenceMode", draft.intelligenceMode) + json.put("cloudAiModelName", draft.cloudAiModelName) + json.put("cloudAiProviderPreset",draft.cloudAiProviderPreset) + if (draft.userName.isNotBlank()) json.put("userName", draft.userName) + repo.setString("ironlog_settings", json.toString(), "json") + // Store API key in EncryptedSharedPreferences + if (draft.cloudAiApiKey.isNotBlank()) { + repo.setString("cloud_ai_api_key", draft.cloudAiApiKey, "encrypted") + } +} +``` + +- [ ] **Step 3: Commit** + +```powershell +git add app/src/main/java/com/ironlog/app/ui/screens/OnboardingScreen.kt +git add app/src/main/java/com/ironlog/app/navigation/AppNavigator.kt +git commit -m "feat(onboarding): wire OnboardingScreen shell with HorizontalPager and AppNavigator" +``` + +--- + +## Task 13: BadgeDefinitions.kt + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/domain/badges/BadgeDefinitions.kt` + +- [ ] **Step 1: Create `BadgeDefinitions.kt`** + +```kotlin +package com.ironlog.app.domain.badges + +enum class BadgeTier { BRONZE, SILVER, GOLD, BLUE } + +/** + * Describes an achievement badge. + * + * [unlockCondition] is a pure function over [AppStats] — no side effects. + * Add new badges here; the UI reads this list at runtime. + */ +data class BadgeDefinition( + val id: String, + val title: String, + val description: String, + val tier: BadgeTier, + val iconResName: String, // drawable resource name, e.g. "ic_badge_dumbbell" + val unlockCondition: (AppStats) -> Boolean, +) + +/** + * Snapshot of user progress used to evaluate [BadgeDefinition.unlockCondition]. + * Extended as new metrics are tracked. + */ +data class AppStats( + val totalWorkouts: Int = 0, + val currentStreak: Int = 0, + val daysSinceFirstWorkout: Int = 0, + val totalVolumeKg: Double = 0.0, + val hasLoggedPR: Boolean = false, + val usedRestTimer: Boolean = false, + val createdPlan: Boolean = false, + val cloudAiActivated: Boolean = false, + val goalModesUsed: Set = emptySet(), + val currentRank: String = "E", + val weeksConsistent: Int = 0, + val consecutiveProgressionWorkouts: Int = 0, +) + +object BadgeDefinitions { + + val all: List = listOf( + BadgeDefinition( + id = "first_workout", + title = "Iron Initiate", + description = "Complete your first workout", + tier = BadgeTier.BRONZE, + iconResName = "ic_badge_dumbbell", + unlockCondition = { it.totalWorkouts >= 1 }, + ), + BadgeDefinition( + id = "streak_3", + title = "Spark", + description = "Achieve a 3-day workout streak", + tier = BadgeTier.BRONZE, + iconResName = "ic_badge_flame", + unlockCondition = { it.currentStreak >= 3 }, + ), + BadgeDefinition( + id = "first_rest_timer", + title = "Patience", + description = "Use the rest timer for the first time", + tier = BadgeTier.BRONZE, + iconResName = "ic_badge_hourglass", + unlockCondition = { it.usedRestTimer }, + ), + BadgeDefinition( + id = "first_plan", + title = "Architect", + description = "Create your first training plan", + tier = BadgeTier.BRONZE, + iconResName = "ic_badge_twin_dumbbells", + unlockCondition = { it.createdPlan }, + ), + BadgeDefinition( + id = "workouts_10", + title = "Charged", + description = "Complete 10 workouts", + tier = BadgeTier.SILVER, + iconResName = "ic_badge_lightning", + unlockCondition = { it.totalWorkouts >= 10 }, + ), + BadgeDefinition( + id = "consistency_4w", + title = "Clockwork", + description = "Train consistently for 4 weeks", + tier = BadgeTier.SILVER, + iconResName = "ic_badge_calendar", + unlockCondition = { it.weeksConsistent >= 4 }, + ), + BadgeDefinition( + id = "first_pr", + title = "Muscle Memory", + description = "Log your first personal record", + tier = BadgeTier.SILVER, + iconResName = "ic_badge_flexed_arm", + unlockCondition = { it.hasLoggedPR }, + ), + BadgeDefinition( + id = "ai_activated", + title = "Augmented", + description = "Activate Cloud AI coaching", + tier = BadgeTier.SILVER, + iconResName = "ic_badge_atom", + unlockCondition = { it.cloudAiActivated }, + ), + BadgeDefinition( + id = "progressive_streak", + title = "Growth Curve", + description = "Progress in 4 consecutive workouts", + tier = BadgeTier.SILVER, + iconResName = "ic_badge_chart", + unlockCondition = { it.consecutiveProgressionWorkouts >= 4 }, + ), + BadgeDefinition( + id = "workouts_50", + title = "Champion", + description = "Complete 50 workouts", + tier = BadgeTier.GOLD, + iconResName = "ic_badge_trophy", + unlockCondition = { it.totalWorkouts >= 50 }, + ), + BadgeDefinition( + id = "streak_30", + title = "Ironclad", + description = "Achieve a 30-day workout streak", + tier = BadgeTier.GOLD, + iconResName = "ic_badge_shield", + unlockCondition = { it.currentStreak >= 30 }, + ), + BadgeDefinition( + id = "workouts_100", + title = "Sovereign", + description = "Complete 100 workouts", + tier = BadgeTier.GOLD, + iconResName = "ic_badge_crown", + unlockCondition = { it.totalWorkouts >= 100 }, + ), + BadgeDefinition( + id = "volume_milestone", + title = "Summit", + description = "Lift 100,000 kg total volume", + tier = BadgeTier.GOLD, + iconResName = "ic_badge_mountain", + unlockCondition = { it.totalVolumeKg >= 100_000.0 }, + ), + BadgeDefinition( + id = "member_365", + title = "Eternal", + description = "365 days since your first workout", + tier = BadgeTier.BLUE, + iconResName = "ic_badge_infinity", + unlockCondition = { it.daysSinceFirstWorkout >= 365 }, + ), + BadgeDefinition( + id = "all_goal_modes", + title = "Multiclass", + description = "Train with all 4 goal modes", + tier = BadgeTier.BLUE, + iconResName = "ic_badge_3stars", + unlockCondition = { it.goalModesUsed.size >= 4 }, + ), + BadgeDefinition( + id = "s_rank", + title = "Diamond", + description = "Reach S-Rank status", + tier = BadgeTier.BLUE, + iconResName = "ic_badge_diamond", + unlockCondition = { it.currentRank == "S" }, + ), + ) + + fun evaluate(stats: AppStats): Set = + all.filter { it.unlockCondition(stats) }.map { it.id }.toSet() +} +``` + +- [ ] **Step 2: Write a quick sanity test** + +```kotlin +// app/src/test/java/com/ironlog/app/domain/badges/BadgeDefinitionsTest.kt +package com.ironlog.app.domain.badges + +import org.junit.Assert.* +import org.junit.Test + +class BadgeDefinitionsTest { + + @Test fun `all badges have unique ids`() { + val ids = BadgeDefinitions.all.map { it.id } + assertEquals(ids.size, ids.toSet().size) + } + + @Test fun `all badge tiers are present`() { + val tiers = BadgeDefinitions.all.map { it.tier }.toSet() + assertEquals(setOf(BadgeTier.BRONZE, BadgeTier.SILVER, BadgeTier.GOLD, BadgeTier.BLUE), tiers) + } + + @Test fun `first_workout unlocks after 1 workout`() { + val stats = AppStats(totalWorkouts = 1) + assertTrue("first_workout" in BadgeDefinitions.evaluate(stats)) + } + + @Test fun `no badges unlock for fresh user`() { + val stats = AppStats() + assertTrue(BadgeDefinitions.evaluate(stats).isEmpty()) + } + + @Test fun `streak_3 does not unlock at streak 2`() { + val stats = AppStats(currentStreak = 2) + assertFalse("streak_3" in BadgeDefinitions.evaluate(stats)) + } + + @Test fun `s_rank unlocks when rank is S`() { + val stats = AppStats(currentRank = "S") + assertTrue("s_rank" in BadgeDefinitions.evaluate(stats)) + } +} +``` + +- [ ] **Step 3: Run tests** + +```powershell +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.domain.badges.BadgeDefinitionsTest" +``` + +Expected: 6 tests pass. + +- [ ] **Step 4: Commit** + +```powershell +git add app/src/main/java/com/ironlog/app/domain/badges/BadgeDefinitions.kt +git add app/src/test/java/com/ironlog/app/domain/badges/BadgeDefinitionsTest.kt +git commit -m "feat(badges): add BadgeDefinitions catalog with 16 badges and AppStats model" +``` + +--- + +## Task 14: Full Build Verification + +- [ ] **Step 1: Build release APK** + +```powershell +cd "Z:\KOTLIN\UnifiedPort" +.\gradlew :app:assembleRelease 2>&1 | Select-String -Pattern "BUILD|error:|ERROR" | Select-Object -First 40 +``` + +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 2: Verify no compile errors in onboarding package** + +```powershell +.\gradlew :app:compileReleaseKotlin 2>&1 | Select-String -Pattern "error:|warning:" | Select-Object -First 30 +``` + +- [ ] **Step 3: Run all unit tests** + +```powershell +.\gradlew :app:testDebugUnitTest 2>&1 | Select-String -Pattern "tests|PASSED|FAILED|BUILD" | Select-Object -First 20 +``` + +Expected: all tests pass (at minimum: 8 OnboardingViewModelTest + 6 BadgeDefinitionsTest + 14 WorkoutSuggestionEngineTest = 28 tests) + +- [ ] **Step 4: Final commit** + +```powershell +git add -A +git commit -m "feat(onboarding): complete SYSTEM AWAKENING 8-screen onboarding flow + +- Step1: Cinematic SYSTEM AWAKENING with typewriter text + particles +- Step2: Hunter name input (HUNTER REGISTRATION) +- Step3: Rank classification → seeds progressionStyle + goalMode +- Step4: Weekly day picker + kg/lbs unit toggle +- Step5: Goal mode 2x2 grid +- Step6: AI abilities — local always-on + optional Cloud AI API key +- Step7: Permission grants (camera, health connect, notifications) +- Step8: ARISE cinematic finale with pulsing rank badge +- OnboardingConfig: single source of truth for all copy + colors +- BadgeDefinitions: 16-badge catalog with AppStats evaluation +- All copy in strings_onboarding.xml — fully editable" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ Screen 1 cinematic — Task 4 +- ✅ Screen 2 name input — Task 5 +- ✅ Screen 3 rank selection — Task 6 +- ✅ Screen 4 day picker + units — Task 7 +- ✅ Screen 5 goal mode grid — Task 8 +- ✅ Screen 6 AI key + model — Task 9 +- ✅ Screen 7 permissions — Task 10 +- ✅ Screen 8 ARISE finale — Task 11 +- ✅ Shell + navigation — Task 12 +- ✅ BadgeDefinitions catalog — Task 13 +- ✅ All copy in string resources — Task 1 +- ✅ `OnboardingConfig` single source of truth — Task 1 +- ✅ No hardcoded strings/colors scattered across composables +- ✅ `OnboardingViewModel` atomic save — Task 12 (`settingsRepoSaveOnboardingDataFull`) +- ✅ Gemini free tier info on screen 6 — Task 9 +- ✅ EncryptedSharedPreferences for API key — Task 12 +- ✅ Health Connect permissions — Task 10 +- ✅ Pre-13 notification permission guard — Task 10 + +**Type consistency:** All method names match across tasks: +- `vm.updateUserName` → used in Task 5 and Task 12 ✅ +- `vm.toggleDayIndex` → defined in Task 7, used in Task 12 ✅ +- `vm.setClassification(style, goal)` → Task 6 + Task 12 ✅ +- `OnboardingDraft.selectedDayIndices` → added Task 7, used Task 7 + Task 12 ✅ +- `settingsRepoSaveOnboardingDataFull(draft)` → Task 12 only ✅ + +**Placeholder check:** No TBD/TODO in any task. All code blocks are complete. Icon drawables noted as "replace with project's actual drawables" in Task 8 with explicit instructions. diff --git a/docs/superpowers/plans/2026-05-18-plan-sharing-qr.md b/docs/superpowers/plans/2026-05-18-plan-sharing-qr.md new file mode 100644 index 0000000..88348f0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-plan-sharing-qr.md @@ -0,0 +1,840 @@ +# Plan Sharing via QR Code + Deep Link Implementation Plan + +> **Status:** Historical execution plan. Its checkboxes were not backfilled and are not a current backlog. Use `AGENTS.md` and the current source/tests as the authoritative project state. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow users to share any training plan as a QR code that another user can scan to instantly import it, plus fix the existing FileProvider bugs that cause plan export crashes. + +**Architecture:** A `PlanQrCodec` serializes a plan to JSON, GZIP-compresses it, and Base64-encodes it for QR embedding. A `PlanQrShareSheet` composable displays the QR code full-screen. A `PlanQrScanScreen` uses ML Kit Barcode Scanning via the device camera. The `file_paths.xml` FileProvider entries are already fixed — this plan wires up the QR path and ensures the existing JSON share flow works. + +**Tech Stack:** Kotlin, Jetpack Compose, `io.github.g0dkar:qrcode-kotlin:4.2.0`, `com.google.mlkit:barcode-scanning:17.3.0`, existing `kotlinx-serialization-json` (already in deps), existing `ImportExportRepository` + +--- + +## File Map + +| Action | File | +|--------|------| +| Modify | `app/build.gradle.kts` — add QR and ML Kit dependencies | +| Create | `app/src/main/java/com/ironlog/app/domain/sharing/PlanQrCodec.kt` | +| Create | `app/src/main/java/com/ironlog/app/ui/screens/PlanQrShareSheet.kt` | +| Create | `app/src/main/java/com/ironlog/app/ui/screens/PlanQrScanScreen.kt` | +| Create | `app/src/test/java/com/ironlog/app/domain/sharing/PlanQrCodecTest.kt` | +| Modify | `app/src/main/java/com/ironlog/app/ui/screens/PlansScreen.kt` — add QR share button | +| Modify | `app/src/main/AndroidManifest.xml` — add camera permission + deep-link intent filter | + +--- + +### Task 1: Add dependencies + +**Files:** +- Modify: `app/build.gradle.kts` + +- [ ] **Step 1: Read current build.gradle.kts** + +Open `app/build.gradle.kts` and confirm the `dependencies` block ends before the closing `}`. + +- [ ] **Step 2: Add QR and ML Kit dependencies** + +Inside the `dependencies { }` block, add after the existing entries: + +```kotlin + // QR code generation (pure-Kotlin, no native dependencies) + implementation("io.github.g0dkar:qrcode-kotlin-android:4.2.0") + + // ML Kit Barcode Scanning — camera QR scan + implementation("com.google.mlkit:barcode-scanning:17.3.0") + + // CameraX — required by ML Kit scanner + implementation("androidx.camera:camera-camera2:1.4.2") + implementation("androidx.camera:camera-lifecycle:1.4.2") + implementation("androidx.camera:camera-view:1.4.2") +``` + +- [ ] **Step 3: Sync Gradle** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD|Download" | Select-Object -Last 20 +``` + +Expected: `BUILD SUCCESSFUL` after dependency download. + +- [ ] **Step 4: Commit** + +``` +git add app/build.gradle.kts +git commit -m "build: add qrcode-kotlin, ML Kit barcode-scanning, CameraX dependencies" +``` + +--- + +### Task 2: PlanQrCodec — serialize, compress, QR-encode + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/domain/sharing/PlanQrCodec.kt` +- Test: `app/src/test/java/com/ironlog/app/domain/sharing/PlanQrCodecTest.kt` + +- [ ] **Step 1: Write failing tests** + +```kotlin +// app/src/test/java/com/ironlog/app/domain/sharing/PlanQrCodecTest.kt +package com.ironlog.app.domain.sharing + +import com.ironlog.app.ui.model.UiPlan +import com.ironlog.app.ui.model.UiPlanDay +import com.ironlog.app.ui.model.UiPlanExercise +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlanQrCodecTest { + + private val codec = PlanQrCodec() + + private fun makePlan(dayCount: Int = 3, exercisesPerDay: Int = 5) = UiPlan( + id = "plan-1", + name = "Test Plan", + goal = "Strength", + description = "Test", + days = (1..dayCount).map { d -> + UiPlanDay( + id = "day-$d", + name = "Day $d", + exercises = (1..exercisesPerDay).map { e -> + UiPlanExercise( + id = "ex-$d-$e", + exerciseId = "exercise-$e", + name = "Exercise $e", + sets = 4, + reps = "8-12", + restSeconds = 90, + ) + }, + ) + }, + ) + + @Test fun `encodeToPaylod produces non-empty string`() { + val payload = codec.encodeToPayload(makePlan()) + assertTrue(payload.isNotBlank()) + } + + @Test fun `decodeFromPayload round-trips the plan`() { + val original = makePlan() + val payload = codec.encodeToPayload(original) + val decoded = codec.decodeFromPayload(payload) + assertNotNull(decoded) + assertEquals(original.name, decoded!!.name) + assertEquals(original.days.size, decoded.days.size) + assertEquals(original.days[0].exercises.size, decoded.days[0].exercises.size) + assertEquals(original.days[0].exercises[0].name, decoded.days[0].exercises[0].name) + } + + @Test fun `payload for 7-day plan fits QR v40 limit (4296 chars)`() { + val bigPlan = makePlan(dayCount = 7, exercisesPerDay = 8) + val payload = codec.encodeToPayload(bigPlan) + assertTrue("Payload length ${payload.length} exceeds QR v40 limit of 4296", + payload.length <= 4296) + } + + @Test fun `decodeFromPayload returns null for garbage input`() { + val decoded = codec.decodeFromPayload("not-valid-base64!@#$") + assertEquals(null, decoded) + } + + @Test fun `decodeFromPayload returns null for empty string`() { + assertEquals(null, codec.decodeFromPayload("")) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.domain.sharing.PlanQrCodecTest" 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 5 +``` + +Expected: compilation error — `PlanQrCodec` not found. + +- [ ] **Step 3: Implement PlanQrCodec** + +```kotlin +// app/src/main/java/com/ironlog/app/domain/sharing/PlanQrCodec.kt +package com.ironlog.app.domain.sharing + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import com.ironlog.app.ui.model.UiPlan +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import qrcode.QRCode +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.Base64 +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +/** + * Converts a [UiPlan] to a compact Base64-encoded payload suitable for QR codes, + * and back again. + * + * Pipeline: UiPlan → JSON → GZIP → Base64 → QR + * Reverse: QR text → Base64 → GZIP decompress → JSON → UiPlan + */ +class PlanQrCodec { + + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + /** + * Encode [plan] to a QR-embeddable string. + * Returns Base64(GZIP(JSON)) of the plan. + */ + fun encodeToPayload(plan: UiPlan): String { + val jsonString = json.encodeToString(plan) + val compressed = gzip(jsonString.toByteArray(Charsets.UTF_8)) + return Base64.getUrlEncoder().withoutPadding().encodeToString(compressed) + } + + /** + * Decode a [payload] string back to a [UiPlan]. + * Returns null if the payload is malformed or cannot be decoded. + */ + fun decodeFromPayload(payload: String): UiPlan? { + if (payload.isBlank()) return null + return runCatching { + val compressed = Base64.getUrlDecoder().decode(payload) + val jsonBytes = gunzip(compressed) + json.decodeFromString(String(jsonBytes, Charsets.UTF_8)) + }.getOrNull() + } + + /** + * Generate a [Bitmap] QR code from [payload]. + * Returns null if payload is too large for a QR code. + */ + fun generateQrBitmap(payload: String, sizePx: Int = 800): Bitmap? { + return runCatching { + val qrCode = QRCode.ofSquares() + .withColor(Color.BLACK) + .withBackgroundColor(Color.WHITE) + .build(payload) + val rendered = qrCode.render() + val image = rendered.nativeImage() as java.awt.image.BufferedImage + // Convert BufferedImage to Android Bitmap + val bmp = Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + for (y in 0 until image.height) { + for (x in 0 until image.width) { + bmp.setPixel(x, y, image.getRGB(x, y)) + } + } + Bitmap.createScaledBitmap(bmp, sizePx, sizePx, false) + }.getOrNull() + } + + // ── Compression helpers ─────────────────────────────────────────────── + + private fun gzip(data: ByteArray): ByteArray { + val bos = ByteArrayOutputStream() + GZIPOutputStream(bos).use { it.write(data) } + return bos.toByteArray() + } + + private fun gunzip(data: ByteArray): ByteArray { + val bis = ByteArrayInputStream(data) + return GZIPInputStream(bis).use { it.readBytes() } + } +} +``` + +> **Note:** The `qrcode-kotlin-android` library's rendering pipeline differs from the pure-JVM version. If `nativeImage()` returns an Android-specific type rather than `BufferedImage`, replace the pixel-copy loop in `generateQrBitmap` with the library's built-in Android bitmap export method. Check the library docs at https://github.com/g0dkar/qrcode-kotlin for the Android-specific API (likely `QRCode.ofSquares().build(data).render().toAndroidBitmap()`). + +- [ ] **Step 4: Run tests** + +``` +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.domain.sharing.PlanQrCodecTest" +``` + +Expected: `BUILD SUCCESSFUL`, all tests pass. + +- [ ] **Step 5: Commit** + +``` +git add app/src/main/java/com/ironlog/app/domain/sharing/PlanQrCodec.kt +git add app/src/test/java/com/ironlog/app/domain/sharing/PlanQrCodecTest.kt +git commit -m "feat: add PlanQrCodec for plan JSON → GZIP → Base64 → QR roundtrip" +``` + +--- + +### Task 3: PlanQrShareSheet — full-screen QR display + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/PlanQrShareSheet.kt` + +- [ ] **Step 1: Implement PlanQrShareSheet** + +```kotlin +// app/src/main/java/com/ironlog/app/ui/screens/PlanQrShareSheet.kt +package com.ironlog.app.ui.screens + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.ironlog.app.domain.sharing.PlanQrCodec +import com.ironlog.app.ui.model.UiPlan +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlanQrShareSheet( + plan: UiPlan, + onDismiss: () -> Unit, + onShareText: (payload: String) -> Unit, +) { + val codec = remember { PlanQrCodec() } + val context = LocalContext.current + + var qrBitmap: Bitmap? by remember { mutableStateOf(null) } + var payload: String by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(true) } + var error: String? by remember { mutableStateOf(null) } + + LaunchedEffect(plan.id) { + isLoading = true + error = null + withContext(Dispatchers.Default) { + runCatching { + val encoded = codec.encodeToPayload(plan) + val bmp = codec.generateQrBitmap(encoded, sizePx = 800) + if (bmp != null) { + payload = encoded + qrBitmap = bmp + } else { + error = "Plan is too large to fit in a QR code." + } + }.onFailure { + error = "Failed to generate QR code: ${it.message}" + } + } + isLoading = false + } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + "Share Plan", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + plan.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + when { + isLoading -> { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + Text("Generating QR code…", style = MaterialTheme.typography.bodySmall) + } + + error != null -> { + Text( + error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } + + qrBitmap != null -> { + // White QR container (required for scanner contrast) + Box( + modifier = Modifier + .size(280.dp) + .background(Color.White, RoundedCornerShape(12.dp)) + .padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Image( + bitmap = qrBitmap!!.asImageBitmap(), + contentDescription = "QR code for ${plan.name}", + modifier = Modifier.fillMaxSize(), + ) + } + + Text( + "Scan with IronLog to import this plan", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Also offer text payload share (fallback for large plans) + OutlinedButton( + onClick = { onShareText(payload) }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Default.Share, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Share as text link instead") + } + } + } + + Spacer(Modifier.height(8.dp)) + } + } +} +``` + +- [ ] **Step 2: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Commit** + +``` +git add app/src/main/java/com/ironlog/app/ui/screens/PlanQrShareSheet.kt +git commit -m "feat: add PlanQrShareSheet composable with full-screen QR display" +``` + +--- + +### Task 4: PlanQrScanScreen — ML Kit camera scanner + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/PlanQrScanScreen.kt` +- Modify: `app/src/main/AndroidManifest.xml` — CAMERA permission + +- [ ] **Step 1: Add CAMERA permission to AndroidManifest.xml** + +In `app/src/main/AndroidManifest.xml`, inside the `` tag before ``, add: + +```xml + + +``` + +- [ ] **Step 2: Implement PlanQrScanScreen** + +```kotlin +// app/src/main/java/com/ironlog/app/ui/screens/PlanQrScanScreen.kt +package com.ironlog.app.ui.screens + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import com.ironlog.app.domain.sharing.PlanQrCodec +import com.ironlog.app.ui.model.UiPlan +import java.util.concurrent.Executors + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalGetImage::class) +@Composable +fun PlanQrScanScreen( + onPlanScanned: (UiPlan) -> Unit, + onBack: () -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val codec = remember { PlanQrCodec() } + + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + ) + } + var scanError: String? by remember { mutableStateOf(null) } + var isProcessing by remember { mutableStateOf(false) } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + hasCameraPermission = granted + } + + LaunchedEffect(Unit) { + if (!hasCameraPermission) { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Scan Plan QR") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + ) + } + ) { padding -> + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + when { + !hasCameraPermission -> { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Camera permission is required to scan QR codes.") + Spacer(Modifier.height(8.dp)) + Button(onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }) { + Text("Grant Permission") + } + } + } + + else -> { + val executor = remember { Executors.newSingleThreadExecutor() } + var scanned by remember { mutableStateOf(false) } + + AndroidView( + factory = { ctx -> + val previewView = PreviewView(ctx) + val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val barcodeScanner = BarcodeScanning.getClient() + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { imageAnalysis -> + imageAnalysis.setAnalyzer(executor) { imageProxy -> + if (scanned) { + imageProxy.close() + return@setAnalyzer + } + val mediaImage = imageProxy.image + if (mediaImage != null) { + val img = InputImage.fromMediaImage( + mediaImage, imageProxy.imageInfo.rotationDegrees + ) + barcodeScanner.process(img) + .addOnSuccessListener { barcodes -> + barcodes.firstOrNull { it.format == Barcode.FORMAT_QR_CODE } + ?.rawValue + ?.let { raw -> + val plan = codec.decodeFromPayload(raw) + if (plan != null && !scanned) { + scanned = true + onPlanScanned(plan) + } else if (plan == null) { + scanError = "QR code is not an IronLog plan." + } + } + } + .addOnCompleteListener { imageProxy.close() } + } else { + imageProxy.close() + } + } + } + + runCatching { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis, + ) + } + }, ContextCompat.getMainExecutor(ctx)) + previewView + }, + modifier = Modifier.fillMaxSize(), + ) + + // Scan frame overlay + Box( + modifier = Modifier + .size(250.dp) + .border(3.dp, Color.White, RoundedCornerShape(12.dp)) + ) + + // Status text overlay at bottom + Column( + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (scanError != null) { + Text(scanError!!, color = MaterialTheme.colorScheme.error) + } else { + Text("Point camera at a plan QR code", color = Color.White) + } + } + } + } + } + } +} +``` + +- [ ] **Step 3: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Commit** + +``` +git add app/src/main/java/com/ironlog/app/ui/screens/PlanQrScanScreen.kt +git add app/src/main/AndroidManifest.xml +git commit -m "feat: add PlanQrScanScreen with ML Kit barcode scanning and camera permission" +``` + +--- + +### Task 5: Wire QR share + scan into PlansScreen and navigation + +**Files:** +- Modify: `app/src/main/java/com/ironlog/app/ui/screens/PlansScreen.kt` +- Modify: `app/src/main/java/com/ironlog/app/ui/navigation/AppNavigation.kt` + +- [ ] **Step 1: Read PlansScreen.kt — find plan card actions area** + +Open `app/src/main/java/com/ironlog/app/ui/screens/PlansScreen.kt`. Locate the composable that renders each plan card (likely a `PlanCard` or inline lambda). Find where the share/export button is currently placed. + +- [ ] **Step 2: Add QR share button to each plan card** + +In the plan card actions area, add alongside the existing export button: + +```kotlin +var showQrSheet by remember { mutableStateOf(false) } +var qrTargetPlan: UiPlan? by remember { mutableStateOf(null) } + +// In each plan card's action row: +IconButton(onClick = { + qrTargetPlan = plan + showQrSheet = true +}) { + Icon( + imageVector = Icons.Default.QrCode, + contentDescription = "Share plan as QR", + ) +} + +// After the plan list, show the bottom sheet when triggered: +if (showQrSheet && qrTargetPlan != null) { + PlanQrShareSheet( + plan = qrTargetPlan!!, + onDismiss = { showQrSheet = false; qrTargetPlan = null }, + onShareText = { payload -> + // Share via Android share sheet as plain text + val sendIntent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + putExtra(android.content.Intent.EXTRA_TEXT, + "ironlog://import?plan=$payload") + type = "text/plain" + } + context.startActivity(android.content.Intent.createChooser(sendIntent, "Share Plan")) + }, + ) +} +``` + +Add imports: +```kotlin +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QrCode +import com.ironlog.app.ui.screens.PlanQrShareSheet +``` + +- [ ] **Step 3: Add "Scan QR" FAB or button to PlansScreen** + +In PlansScreen, add a secondary FAB or toolbar button: + +```kotlin +// In the Scaffold FAB slot (as an extended FAB or small FAB): +FloatingActionButton( + onClick = { navController.navigate("scanPlanQr") }, + containerColor = MaterialTheme.colorScheme.secondaryContainer, +) { + Icon(Icons.Default.QrCodeScanner, contentDescription = "Scan QR to import plan") +} +``` + +- [ ] **Step 4: Add scanPlanQr route to AppNavigation.kt** + +```kotlin +composable("scanPlanQr") { + PlanQrScanScreen( + onPlanScanned = { plan -> + // Import the scanned plan via the existing ImportExportRepository + // Navigate back and show success snackbar + navController.popBackStack() + // Trigger import — call the ViewModel's import function + // vm.importPlanFromQr(plan) — add this method to the ViewModel + }, + onBack = { navController.popBackStack() }, + ) +} +``` + +- [ ] **Step 5: Add importPlanFromQr to the plans ViewModel** + +Open the existing plans ViewModel (likely `WatermelonAppDataViewModel` or `PlansViewModel`). Add: + +```kotlin +/** + * Import a plan that was decoded from a QR code payload. + * Assigns new UUIDs to avoid ID collisions with existing plans. + */ +fun importPlanFromQr(plan: UiPlan) { + viewModelScope.launch(Dispatchers.IO) { + val newPlan = plan.copy( + id = java.util.UUID.randomUUID().toString(), + name = "${plan.name} (imported)", + isActive = false, + days = plan.days.map { day -> + day.copy( + id = java.util.UUID.randomUUID().toString(), + exercises = day.exercises.map { ex -> + ex.copy(id = java.util.UUID.randomUUID().toString()) + } + ) + } + ) + // Persist using the existing plan repository + planRepository.insert(newPlan) + } +} +``` + +- [ ] **Step 6: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 7: Commit** + +``` +git add app/src/main/java/com/ironlog/app/ui/screens/PlansScreen.kt +git add app/src/main/java/com/ironlog/app/ui/navigation/AppNavigation.kt +git commit -m "feat: wire QR share sheet and QR scan screen into PlansScreen and navigation" +``` + +--- + +### Task 6: FileProvider bug fixes verification + +> The `file_paths.xml` fixes were applied earlier in this session (see AGENTS.md entry 2026-05-18 — Audit Fix). This task verifies the fixes are in place and ensures the existing JSON plan export still works end-to-end. + +**Files:** +- Verify: `app/src/main/res/xml/file_paths.xml` + +- [ ] **Step 1: Verify file_paths.xml has all required entries** + +Read `app/src/main/res/xml/file_paths.xml` and confirm it contains exactly: + +```xml + + + + + + + + + +``` + +If any entry is missing, add it now. + +- [ ] **Step 2: Build to verify FileProvider config is valid** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 5 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Commit (only if file_paths.xml was modified in this step)** + +``` +git add app/src/main/res/xml/file_paths.xml +git commit -m "fix: ensure all FileProvider paths are registered to prevent share crashes" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ QR code generation from plan JSON (compress → Base64 → QR bitmap) +- ✅ Plan round-trip: encode → decode preserves all fields +- ✅ 7-day plan fits in QR v40 (tested in `PlanQrCodecTest`) +- ✅ Full-screen QR display in `PlanQrShareSheet` +- ✅ ML Kit camera scanner in `PlanQrScanScreen` +- ✅ Camera permission request flow +- ✅ Text payload fallback via Android share sheet +- ✅ QR share button on plan cards in PlansScreen +- ✅ "Scan QR" entry point in PlansScreen +- ✅ Import assigns new UUIDs to prevent ID collisions +- ✅ FileProvider entries verified +- ✅ TDD: `PlanQrCodecTest` covers encode, decode, round-trip, size limit, error cases + +**Type consistency:** `UiPlan` is `@Immutable` and `@Serializable` (or will need `@Serializable` annotation added if not already present — check `UiModels.kt` before implementing). `PlanQrCodec` uses `Json { ignoreUnknownKeys = true }` for forward compatibility. + +**Critical check before implementing Task 2:** Verify `UiPlan`, `UiPlanDay`, `UiPlanExercise` in `UiModels.kt` have `@Serializable` annotations. If they don't, add them (they extend no non-serializable base, so this is safe). diff --git a/docs/superpowers/plans/2026-05-18-solo-leveling-gamification.md b/docs/superpowers/plans/2026-05-18-solo-leveling-gamification.md new file mode 100644 index 0000000..68293bb --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-solo-leveling-gamification.md @@ -0,0 +1,1811 @@ +# Solo Leveling Gamification Implementation Plan + +> **Status:** Historical execution plan. Its checkboxes were not backfilled and are not a current backlog. Use `AGENTS.md` and the current source/tests as the authoritative project state. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Layer a full RPG progression system onto IronLog — XP, levels (1–100), Solo Leveling ranks (E→S→National), weekly-goal-based streaks with bodyweight make-up quests, adaptive monthly dungeon-boss quests, six derived RPG stats (STR/VIT/END/AGI/WIS/LUK), and a Status Window screen. + +**Architecture:** Three pure-Kotlin engines (`XpEngine`, `StatEngine`, `StreakEngine`, `DungeonBossEngine`) hold all logic. One ObjectBox entity (`GamificationProfileEntity`) persists state. One ObjectBox entity (`QuestEntity`) persists active quests. A new `StatusWindowScreen` composable displays the character sheet. A `MakeUpQuestSheet` bottom-sheet composable handles bodyweight streak-save quests. The existing `WatermelonAppDataViewModel` gains a `GamificationViewModel` companion that bridges engines ↔ UI state. Badge art is placeholder SVG/resource drawables (actual badge images to be generated separately via AI image generation). + +**Tech Stack:** Kotlin, ObjectBox 4.0.3, Jetpack Compose, existing `HistoryEntry`/`IronLogSettings` models + +--- + +## File Map + +| Action | File | +|--------|------| +| Create | `app/src/main/java/com/ironlog/app/data/entity/GamificationProfileEntity.kt` | +| Create | `app/src/main/java/com/ironlog/app/data/entity/QuestEntity.kt` | +| Create | `app/src/main/java/com/ironlog/app/domain/gamification/XpEngine.kt` | +| Create | `app/src/main/java/com/ironlog/app/domain/gamification/StatEngine.kt` | +| Create | `app/src/main/java/com/ironlog/app/domain/gamification/StreakEngine.kt` | +| Create | `app/src/main/java/com/ironlog/app/domain/gamification/DungeonBossEngine.kt` | +| Create | `app/src/main/java/com/ironlog/app/domain/gamification/QuestEngine.kt` | +| Create | `app/src/main/java/com/ironlog/app/ui/viewmodel/GamificationViewModel.kt` | +| Create | `app/src/main/java/com/ironlog/app/ui/screens/StatusWindowScreen.kt` | +| Create | `app/src/main/java/com/ironlog/app/ui/screens/MakeUpQuestSheet.kt` | +| Create | `app/src/test/java/com/ironlog/app/domain/gamification/XpEngineTest.kt` | +| Create | `app/src/test/java/com/ironlog/app/domain/gamification/StatEngineTest.kt` | +| Create | `app/src/test/java/com/ironlog/app/domain/gamification/StreakEngineTest.kt` | +| Create | `app/src/test/java/com/ironlog/app/domain/gamification/DungeonBossEngineTest.kt` | +| Modify | `app/src/main/java/com/ironlog/app/ui/navigation/AppNavigation.kt` — add `statusWindow` route | +| Modify | `app/src/main/java/com/ironlog/app/ui/screens/HomeScreen.kt` — XP bar + streak badge | + +--- + +### Task 1: XpEngine — level curve and rank system + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/domain/gamification/XpEngine.kt` +- Test: `app/src/test/java/com/ironlog/app/domain/gamification/XpEngineTest.kt` + +- [ ] **Step 1: Write failing tests** + +```kotlin +// app/src/test/java/com/ironlog/app/domain/gamification/XpEngineTest.kt +package com.ironlog.app.domain.gamification + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.roundToLong + +class XpEngineTest { + + private val engine = XpEngine() + + @Test fun `level 1 requires 100 XP`() { + assertEquals(100L, engine.xpForLevel(1)) + } + + @Test fun `level 2 requires 283 XP (100 * 2^1_5 rounded)`() { + val expected = (100.0 * Math.pow(2.0, 1.5)).roundToLong() + assertEquals(expected, engine.xpForLevel(2)) + } + + @Test fun `level 10 requires correct XP`() { + val expected = (100.0 * Math.pow(10.0, 1.5)).roundToLong() + assertEquals(expected, engine.xpForLevel(10)) + } + + @Test fun `xp curve is strictly increasing`() { + val xps = (1..20).map { engine.xpForLevel(it) } + for (i in 1 until xps.size) { + assertTrue("Level ${i+1} xp should exceed level $i xp", xps[i] > xps[i - 1]) + } + } + + @Test fun `rank E for level 1`() { + assertEquals("E", engine.rankForLevel(1)) + } + + @Test fun `rank D for level 11`() { + assertEquals("D", engine.rankForLevel(11)) + } + + @Test fun `rank C for level 21`() { + assertEquals("C", engine.rankForLevel(21)) + } + + @Test fun `rank B for level 36`() { + assertEquals("B", engine.rankForLevel(36)) + } + + @Test fun `rank A for level 51`() { + assertEquals("A", engine.rankForLevel(51)) + } + + @Test fun `rank S for level 71`() { + assertEquals("S", engine.rankForLevel(71)) + } + + @Test fun `rank National for level 91`() { + assertEquals("National", engine.rankForLevel(91)) + } + + @Test fun `xpForAction WORKOUT_COMPLETE returns positive value`() { + assertTrue(engine.xpForAction(XpAction.WORKOUT_COMPLETE) > 0) + } + + @Test fun `xpForAction PR_SET returns more than WORKOUT_COMPLETE`() { + assertTrue(engine.xpForAction(XpAction.PR_SET) > engine.xpForAction(XpAction.WORKOUT_COMPLETE)) + } + + @Test fun `xpForAction STREAK_WEEK returns positive value`() { + assertTrue(engine.xpForAction(XpAction.STREAK_WEEK) > 0) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.domain.gamification.XpEngineTest" 2>&1 | Select-String -Pattern "error:|FAIL|BUILD" | Select-Object -Last 10 +``` + +Expected: compilation error — `XpEngine` and `XpAction` not found. + +- [ ] **Step 3: Implement XpEngine** + +```kotlin +// app/src/main/java/com/ironlog/app/domain/gamification/XpEngine.kt +package com.ironlog.app.domain.gamification + +import kotlin.math.pow +import kotlin.math.roundToLong + +enum class XpAction { + WORKOUT_COMPLETE, // +50 XP + PR_SET, // +100 XP + STREAK_WEEK, // +75 XP per qualifying week milestone + QUEST_COMPLETE, // +150 XP + DUNGEON_BOSS_SLAY, // +500 XP + MAKEUP_QUEST, // +25 XP (streak-save bodyweight circuit) + FIRST_WORKOUT, // +200 XP one-time +} + +class XpEngine { + + /** + * XP required to reach [level] (1-indexed). + * Curve: 100 × level^1.5, rounded to nearest Long. + */ + fun xpForLevel(level: Int): Long = (100.0 * level.toDouble().pow(1.5)).roundToLong() + + /** + * Solo Leveling rank thresholds by level. + * E: 1–10 | D: 11–20 | C: 21–35 | B: 36–50 | A: 51–70 | S: 71–90 | National: 91+ + */ + fun rankForLevel(level: Int): String = when { + level >= 91 -> "National" + level >= 71 -> "S" + level >= 51 -> "A" + level >= 36 -> "B" + level >= 21 -> "C" + level >= 11 -> "D" + else -> "E" + } + + /** XP awarded for a given [action]. */ + fun xpForAction(action: XpAction): Int = when (action) { + XpAction.WORKOUT_COMPLETE -> 50 + XpAction.PR_SET -> 100 + XpAction.STREAK_WEEK -> 75 + XpAction.QUEST_COMPLETE -> 150 + XpAction.DUNGEON_BOSS_SLAY -> 500 + XpAction.MAKEUP_QUEST -> 25 + XpAction.FIRST_WORKOUT -> 200 + } + + /** + * Given current [totalXp], return the current level (1-based). + * Level advances when accumulated XP meets the next threshold. + */ + fun levelFromTotalXp(totalXp: Long): Int { + var level = 1 + var accumulated = 0L + while (level < 100) { + accumulated += xpForLevel(level) + if (totalXp < accumulated) break + level++ + } + return level.coerceAtMost(100) + } + + /** + * XP progress within the current level (0L..xpForLevel(currentLevel)). + */ + fun xpInCurrentLevel(totalXp: Long): Long { + val level = levelFromTotalXp(totalXp) + val xpAtLevelStart = (1 until level).sumOf { xpForLevel(it) } + return (totalXp - xpAtLevelStart).coerceAtLeast(0L) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +``` +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.domain.gamification.XpEngineTest" +``` + +Expected: `BUILD SUCCESSFUL`, all tests pass. + +- [ ] **Step 5: Commit** + +``` +git add app/src/main/java/com/ironlog/app/domain/gamification/XpEngine.kt +git add app/src/test/java/com/ironlog/app/domain/gamification/XpEngineTest.kt +git commit -m "feat: add XpEngine with Solo Leveling rank curve and XP action rewards" +``` + +--- + +### Task 2: StreakEngine — weekly-goal-based streaks with make-up quest credit + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/domain/gamification/StreakEngine.kt` +- Test: `app/src/test/java/com/ironlog/app/domain/gamification/StreakEngineTest.kt` + +- [ ] **Step 1: Write failing tests** + +```kotlin +// app/src/test/java/com/ironlog/app/domain/gamification/StreakEngineTest.kt +package com.ironlog.app.domain.gamification + +import com.ironlog.app.ui.model.HistoryEntry +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDate + +class StreakEngineTest { + + private val engine = StreakEngine() + + private fun entry(date: LocalDate) = HistoryEntry( + id = date.toString(), + date = date.toString(), + ) + + // Week of 2026-05-18 is ISO week 21 (Mon May 18 – Sun May 24) + private val monday = LocalDate.of(2026, 5, 18) + + @Test fun `empty history gives streak 0`() { + assertEquals(0, engine.computeStreakWeeks(emptyList(), weeklyGoal = 4, makeupCompletions = emptyMap())) + } + + @Test fun `one qualifying week gives streak 1`() { + // 4 workouts in week 21 satisfies goal=4 + val history = (0..3).map { entry(monday.plusDays(it.toLong())) } + assertEquals(1, engine.computeStreakWeeks(history, weeklyGoal = 4, makeupCompletions = emptyMap())) + } + + @Test fun `two consecutive qualifying weeks give streak 2`() { + val week1 = (0..3).map { entry(monday.plusDays(it.toLong())) } + val week2 = (7..10).map { entry(monday.plusDays(it.toLong())) } + val history = week1 + week2 + assertEquals(2, engine.computeStreakWeeks(history, weeklyGoal = 4, makeupCompletions = emptyMap())) + } + + @Test fun `gap week without makeup breaks streak`() { + val week1 = (0..3).map { entry(monday.plusDays(it.toLong())) } + // week 2: only 3 sessions (goal=4), no makeup + val week2 = (7..9).map { entry(monday.plusDays(it.toLong())) } + val week3 = (14..17).map { entry(monday.plusDays(it.toLong())) } + val history = week1 + week2 + week3 + // streak should count from week3 backward: week2 is not qualifying and no makeup + val result = engine.computeStreakWeeks(history, weeklyGoal = 4, makeupCompletions = emptyMap()) + assertEquals(1, result) + } + + @Test fun `gap week with makeup quest saves streak`() { + val week1 = (0..3).map { entry(monday.plusDays(it.toLong())) } + // week 2: only 3 sessions (goal-1), but makeup completed + val week2 = (7..9).map { entry(monday.plusDays(it.toLong())) } + val week3 = (14..17).map { entry(monday.plusDays(it.toLong())) } + val history = week1 + week2 + week3 + // ISO week 22 makeup completed + val makeups = mapOf("2026-W22" to 1) + val result = engine.computeStreakWeeks(history, weeklyGoal = 4, makeupCompletions = makeups) + assertEquals(3, result) + } + + @Test fun `makeup does not save when sessions is goal minus 2 or more short`() { + val week1 = (0..3).map { entry(monday.plusDays(it.toLong())) } + // week 2: only 2 sessions (goal-2), makeup present — too short, can't save + val week2 = (7..8).map { entry(monday.plusDays(it.toLong())) } + val week3 = (14..17).map { entry(monday.plusDays(it.toLong())) } + val history = week1 + week2 + week3 + val makeups = mapOf("2026-W22" to 1) + val result = engine.computeStreakWeeks(history, weeklyGoal = 4, makeupCompletions = makeups) + assertEquals(1, result) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.domain.gamification.StreakEngineTest" 2>&1 | Select-String -Pattern "error:|BUILD" | Select-Object -Last 5 +``` + +Expected: compilation error. + +- [ ] **Step 3: Implement StreakEngine** + +```kotlin +// app/src/main/java/com/ironlog/app/domain/gamification/StreakEngine.kt +package com.ironlog.app.domain.gamification + +import com.ironlog.app.ui.model.HistoryEntry +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.temporal.WeekFields +import java.util.Locale + +class StreakEngine { + + private val isoWeek = WeekFields.ISO + + /** + * ISO week key, e.g. "2026-W21". + */ + private fun LocalDate.isoWeekKey(): String { + val week = get(isoWeek.weekOfWeekBasedYear()) + val year = get(isoWeek.weekBasedYear()) + return "$year-W${week.toString().padStart(2, '0')}" + } + + /** + * Computes the current streak in qualifying weeks. + * + * A week qualifies if: + * - sessions in that week >= [weeklyGoal], OR + * - sessions == weeklyGoal - 1 AND [makeupCompletions][weekKey] >= 1 + * + * The streak counts backward from the most recent qualifying week. + * A missing week (no workouts at all, not even goal-1+makeup) breaks the streak. + * + * @param history All workout history entries. + * @param weeklyGoal Sessions required per week (from IronLogSettings.weeklyGoalDays). + * @param makeupCompletions Map of ISO-week-key → number of makeup quests completed. + */ + fun computeStreakWeeks( + history: List, + weeklyGoal: Int, + makeupCompletions: Map, + ): Int { + if (history.isEmpty()) return 0 + + // Group workouts by ISO week key + val fmt = DateTimeFormatter.ISO_LOCAL_DATE + val sessionsByWeek: Map = history + .mapNotNull { entry -> + runCatching { LocalDate.parse(entry.date.take(10), fmt) }.getOrNull() + ?.isoWeekKey() + } + .groupingBy { it } + .eachCount() + + if (sessionsByWeek.isEmpty()) return 0 + + // Sort weeks descending (most recent first) + val sortedWeeks = sessionsByWeek.keys.sortedDescending() + val mostRecentWeek = sortedWeeks.first() + + // Walk backward from the most recent week, count consecutive qualifying weeks + var streak = 0 + var current = LocalDate.parse( + "${ mostRecentWeek.substringBefore("-W") }-01-01", + DateTimeFormatter.ISO_LOCAL_DATE + ).also { + // Parse the ISO week properly + } + + // Simpler approach: walk the sorted weeks list + for (weekKey in sortedWeeks) { + val sessions = sessionsByWeek[weekKey] ?: 0 + val makeups = makeupCompletions[weekKey] ?: 0 + val qualifies = sessions >= weeklyGoal || + (sessions == weeklyGoal - 1 && makeups >= 1) + + if (qualifies) { + streak++ + } else { + break + } + } + + return streak + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +``` +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.domain.gamification.StreakEngineTest" +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 5: Commit** + +``` +git add app/src/main/java/com/ironlog/app/domain/gamification/StreakEngine.kt +git add app/src/test/java/com/ironlog/app/domain/gamification/StreakEngineTest.kt +git commit -m "feat: add StreakEngine with weekly-goal streak model and makeup-quest save" +``` + +--- + +### Task 3: StatEngine — derive RPG stats from workout history + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/domain/gamification/StatEngine.kt` +- Test: `app/src/test/java/com/ironlog/app/domain/gamification/StatEngineTest.kt` + +- [ ] **Step 1: Write failing tests** + +```kotlin +// app/src/test/java/com/ironlog/app/domain/gamification/StatEngineTest.kt +package com.ironlog.app.domain.gamification + +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.HistoryExercise +import com.ironlog.app.ui.model.HistoryExerciseSet +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class StatEngineTest { + + private val engine = StatEngine() + + private fun heavySet(weight: Double, reps: Double) = HistoryExerciseSet( + id = "s1", weight = weight, reps = reps, type = "normal" + ) + + private fun entry( + id: String, + date: String, + durationMin: Int = 60, + exerciseCount: Int = 5, + sets: List = listOf(heavySet(100.0, 5.0)), + ) = HistoryEntry( + id = id, + date = date, + duration = durationMin * 60, + exercises = (1..exerciseCount).map { i -> + HistoryExercise(id = "$id-ex$i", name = "Exercise $i", sets = sets) + }, + ) + + @Test fun `empty history returns all stats at minimum 1`() { + val stats = engine.compute(emptyList(), streak = 0, totalSessions = 0) + assertTrue(stats.str >= 1) + assertTrue(stats.vit >= 1) + assertTrue(stats.end >= 1) + assertTrue(stats.agi >= 1) + assertTrue(stats.wis >= 1) + assertTrue(stats.luk >= 1) + } + + @Test fun `all stats are capped at 999`() { + // Generate 500 heavy workouts + val history = (1..500).map { i -> + entry(id = "e$i", date = "2026-01-${(i % 28 + 1).toString().padStart(2, '0')}", + sets = listOf(heavySet(200.0, 5.0))) + } + val stats = engine.compute(history, streak = 500, totalSessions = 500) + assertTrue(stats.str <= 999) + assertTrue(stats.vit <= 999) + assertTrue(stats.end <= 999) + assertTrue(stats.agi <= 999) + } + + @Test fun `higher estimated 1RM gives higher STR`() { + val light = listOf(entry("a", "2026-05-01", sets = listOf(heavySet(50.0, 10.0)))) + val heavy = listOf(entry("b", "2026-05-01", sets = listOf(heavySet(150.0, 5.0)))) + val statsLight = engine.compute(light, streak = 0, totalSessions = 1) + val statsHeavy = engine.compute(heavy, streak = 0, totalSessions = 1) + assertTrue("Heavier lifter should have more STR", statsHeavy.str > statsLight.str) + } + + @Test fun `longer workout duration gives higher END`() { + val short = listOf(entry("a", "2026-05-01", durationMin = 20)) + val long = listOf(entry("b", "2026-05-01", durationMin = 120)) + val statsShort = engine.compute(short, streak = 0, totalSessions = 1) + val statsLong = engine.compute(long, streak = 0, totalSessions = 1) + assertTrue("Longer workouts should give more END", statsLong.end > statsShort.end) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.domain.gamification.StatEngineTest" 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 5 +``` + +Expected: compilation error. + +- [ ] **Step 3: Implement StatEngine** + +```kotlin +// app/src/main/java/com/ironlog/app/domain/gamification/StatEngine.kt +package com.ironlog.app.domain.gamification + +import com.ironlog.app.ui.model.HistoryEntry +import com.ironlog.app.ui.model.HistoryExerciseSet +import kotlin.math.ln +import kotlin.math.roundToInt + +data class RpgStats( + val str: Int = 1, // Strength — best estimated 1RM across all exercises + val vit: Int = 1, // Vitality — streak + total sessions + val end: Int = 1, // Endurance — average workout duration + val agi: Int = 1, // Agility — exercise variety per session + val wis: Int = 1, // Wisdom — RPE/effort tracking frequency (future; now = 1) + val luk: Int = 1, // Luck — rare events (PRs, streaks, quests) +) + +class StatEngine { + + /** Epley formula: 1RM ≈ weight × (1 + reps/30) */ + private fun epley1rm(weight: Double, reps: Double): Double = + weight * (1.0 + reps / 30.0) + + /** + * Map a raw metric to a 1–999 RPG stat value using logarithmic scaling. + * [value] is the raw metric; [scale] controls how quickly stat grows. + */ + private fun toStat(value: Double, scale: Double): Int = + (1 + (ln(1.0 + value / scale) * 200.0)).roundToInt().coerceIn(1, 999) + + fun compute( + history: List, + streak: Int, + totalSessions: Int, + ): RpgStats { + if (history.isEmpty()) return RpgStats() + + // STR — best estimated 1RM across all sets in all history + val bestOrm: Double = history.flatMap { it.exercises } + .flatMap { it.sets } + .filter { it.type != "warmup" && it.reps > 0 && it.weight > 0 } + .maxOfOrNull { epley1rm(it.weight, it.reps) } ?: 0.0 + + // END — average workout duration in minutes + val avgDurationMin = history.map { it.duration / 60.0 }.average() + + // AGI — average distinct exercises per session + val avgExercises = history.map { it.exercises.size.toDouble() }.average() + + // VIT — blend of streak weeks and total sessions + val vitRaw = streak * 5.0 + totalSessions.toDouble() + + // WIS — placeholder (RPE logging not yet tracked per set); starts at minimum + val wis = 1 + + // LUK — count of PRs + quests (approximated as high-RPE sets for now) + val luk = toStat( + value = history.sumOf { entry -> + entry.exercises.sumOf { ex -> + ex.sets.count { it.rpe != null && (it.rpe ?: 0.0) >= 9.0 }.toDouble() + } + }, + scale = 20.0, + ) + + return RpgStats( + str = toStat(bestOrm, scale = 100.0), + vit = toStat(vitRaw, scale = 50.0), + end = toStat(avgDurationMin, scale = 30.0), + agi = toStat(avgExercises, scale = 5.0), + wis = wis, + luk = luk, + ) + } +} +``` + +- [ ] **Step 4: Run tests** + +``` +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.domain.gamification.StatEngineTest" +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 5: Commit** + +``` +git add app/src/main/java/com/ironlog/app/domain/gamification/StatEngine.kt +git add app/src/test/java/com/ironlog/app/domain/gamification/StatEngineTest.kt +git commit -m "feat: add StatEngine deriving STR/VIT/END/AGI/WIS/LUK from workout history" +``` + +--- + +### Task 4: DungeonBossEngine — adaptive monthly quest + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/domain/gamification/DungeonBossEngine.kt` +- Test: `app/src/test/java/com/ironlog/app/domain/gamification/DungeonBossEngineTest.kt` + +- [ ] **Step 1: Write failing tests** + +```kotlin +// app/src/test/java/com/ironlog/app/domain/gamification/DungeonBossEngineTest.kt +package com.ironlog.app.domain.gamification + +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class DungeonBossEngineTest { + + private val engine = DungeonBossEngine() + + private fun profile( + recentPrCount: Int = 0, + strongExercises: List = emptyList(), + avgWeeklyWorkouts: Double = 3.0, + currentStreak: Int = 2, + ) = UserActivityProfile( + recentPrCount = recentPrCount, + strongExercises = strongExercises, + avgWeeklyWorkouts = avgWeeklyWorkouts, + currentStreak = currentStreak, + ) + + @Test fun `generates a non-null quest for any profile`() { + assertNotNull(engine.generateMonthlyBoss(profile())) + } + + @Test fun `quest for high PR count targets PR exercises`() { + val p = profile( + recentPrCount = 3, + strongExercises = listOf("Bench Press", "Squat", "Deadlift"), + ) + val quest = engine.generateMonthlyBoss(p) + assertTrue("Quest should reference a strong exercise", + p.strongExercises.any { ex -> quest.description.contains(ex, ignoreCase = true) }) + } + + @Test fun `quest target count is achievable (not more than 2x avg)`() { + val p = profile(avgWeeklyWorkouts = 3.0, currentStreak = 2) + val quest = engine.generateMonthlyBoss(p) + // Monthly gym sessions target should not exceed 2× expected monthly count + assertTrue("Quest target ${quest.gymSessionTarget} should be <= ${(p.avgWeeklyWorkouts * 4 * 2).toInt()}", + quest.gymSessionTarget <= (p.avgWeeklyWorkouts * 4 * 2).toInt()) + } + + @Test fun `beginner profile (low sessions) gets an easy quest`() { + val p = profile(avgWeeklyWorkouts = 1.5, currentStreak = 0) + val quest = engine.generateMonthlyBoss(p) + assertTrue("Beginner gym target should be <= 8", quest.gymSessionTarget <= 8) + } + + @Test fun `advanced profile gets harder quest`() { + val beginner = profile(avgWeeklyWorkouts = 2.0, currentStreak = 1) + val advanced = profile(avgWeeklyWorkouts = 5.0, currentStreak = 8, recentPrCount = 4, + strongExercises = listOf("Bench Press", "Squat")) + val questB = engine.generateMonthlyBoss(beginner) + val questA = engine.generateMonthlyBoss(advanced) + assertTrue("Advanced quest should have higher gym target", + questA.gymSessionTarget >= questB.gymSessionTarget) + } + + @Test fun `quest always has non-empty description`() { + val quest = engine.generateMonthlyBoss(profile()) + assertTrue(quest.description.isNotBlank()) + } + + @Test fun `quest always has positive XP reward`() { + val quest = engine.generateMonthlyBoss(profile()) + assertTrue(quest.xpReward > 0) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +``` +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.domain.gamification.DungeonBossEngineTest" 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 5 +``` + +- [ ] **Step 3: Implement DungeonBossEngine** + +```kotlin +// app/src/main/java/com/ironlog/app/domain/gamification/DungeonBossEngine.kt +package com.ironlog.app.domain.gamification + +import kotlin.math.roundToInt + +data class UserActivityProfile( + /** Number of PRs set in the last 60 days. */ + val recentPrCount: Int, + /** Exercises the user has logged ≥3 times in the last 60 days. */ + val strongExercises: List, + /** Average workouts per week over the last 8 weeks. */ + val avgWeeklyWorkouts: Double, + /** Current streak in qualifying weeks. */ + val currentStreak: Int, +) + +data class DungeonBossQuest( + val description: String, + /** Minimum gym sessions required to complete the quest this month. */ + val gymSessionTarget: Int, + /** Optional PR exercises to target (empty = gym sessions only). */ + val prExercises: List, + /** XP awarded on completion. */ + val xpReward: Int, +) + +class DungeonBossEngine { + + /** + * Generates an adaptive monthly dungeon-boss quest calibrated to the user's + * recent activity. Safety guards prevent impossible quests. + * + * Quest types: + * 1. PR Hunt — if recentPrCount >= 2 and strongExercises not empty: + * "Hit a new PR in [X] of your strongest lifts + [N] gym sessions" + * 2. Consistency — if avgWeeklyWorkouts < 3.0: + * "Reach [N] gym sessions this month" + * 3. Streak + Sessions combo — otherwise: + * "Maintain your [streak] week streak and hit [N] sessions" + */ + fun generateMonthlyBoss(profile: UserActivityProfile): DungeonBossQuest { + val expectedMonthly = (profile.avgWeeklyWorkouts * 4.33).roundToInt().coerceAtLeast(1) + + return when { + // PR Hunt path + profile.recentPrCount >= 2 && profile.strongExercises.isNotEmpty() -> { + val targetExercises = profile.strongExercises.take(3.coerceAtMost(profile.strongExercises.size)) + val prTarget = targetExercises.take(2) // require PR in 2 of the top exercises + val sessionTarget = (expectedMonthly + 2).coerceAtMost(expectedMonthly * 2) + DungeonBossQuest( + description = "🏆 BOSS: Hit a new PR in ${prTarget.joinToString(" & ")} " + + "and complete $sessionTarget gym sessions this month.", + gymSessionTarget = sessionTarget, + prExercises = prTarget, + xpReward = 500 + profile.recentPrCount * 50, + ) + } + + // Beginner consistency path + profile.avgWeeklyWorkouts < 3.0 -> { + val sessionTarget = (expectedMonthly + 2).coerceAtMost(8) + DungeonBossQuest( + description = "⚔️ BOSS: Complete $sessionTarget gym sessions this month to prove your commitment.", + gymSessionTarget = sessionTarget, + prExercises = emptyList(), + xpReward = 400, + ) + } + + // Streak + consistency combo + else -> { + val streakTarget = profile.currentStreak + 2 + val sessionTarget = (expectedMonthly + 3).coerceAtMost(expectedMonthly * 2) + DungeonBossQuest( + description = "🔥 BOSS: Keep a $streakTarget-week streak and complete $sessionTarget sessions this month.", + gymSessionTarget = sessionTarget, + prExercises = emptyList(), + xpReward = 450 + profile.currentStreak * 25, + ) + } + } + } +} +``` + +- [ ] **Step 4: Run tests** + +``` +.\gradlew :app:testDebugUnitTest --tests "com.ironlog.app.domain.gamification.DungeonBossEngineTest" +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 5: Commit** + +``` +git add app/src/main/java/com/ironlog/app/domain/gamification/DungeonBossEngine.kt +git add app/src/test/java/com/ironlog/app/domain/gamification/DungeonBossEngineTest.kt +git commit -m "feat: add DungeonBossEngine for adaptive monthly quest generation" +``` + +--- + +### Task 5: ObjectBox entities — GamificationProfileEntity and QuestEntity + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/data/entity/GamificationProfileEntity.kt` +- Create: `app/src/main/java/com/ironlog/app/data/entity/QuestEntity.kt` + +> **Note:** ObjectBox entities require `@Entity` and `@Id`. After adding new entities, rebuild the project to regenerate the ObjectBox schema. No unit tests for entity classes (they are pure data holders). + +- [ ] **Step 1: Create GamificationProfileEntity** + +```kotlin +// app/src/main/java/com/ironlog/app/data/entity/GamificationProfileEntity.kt +package com.ironlog.app.data.entity + +import io.objectbox.annotation.Entity +import io.objectbox.annotation.Id +import io.objectbox.annotation.Unique + +@Entity +data class GamificationProfileEntity( + @Id var id: Long = 0, + + /** Stable user identifier (UUID string, set once on first launch). */ + @Unique var offlineUserId: String = "", + + /** Total accumulated XP across all time. */ + var totalXp: Long = 0L, + + /** Current level (1–100), derived from totalXp but cached for display. */ + var level: Int = 1, + + /** XP within the current level (0..xpForLevel(level)). */ + var xpInLevel: Long = 0L, + + /** Current Solo Leveling rank string: "E", "D", "C", "B", "A", "S", "National". */ + var rank: String = "E", + + /** Active title (unlocked badge name). */ + var activeTitle: String = "Novice Hunter", + + /** JSON-serialized RpgStats for fast display without recomputing. */ + var statsJson: String = "{}", + + /** XP earned this ISO week (for weekly leaderboard — stored, not yet used). */ + var weeklyXp: Int = 0, + + /** ISO week key of last weeklyXp reset, e.g. "2026-W21". */ + var weeklyXpResetWeek: String = "", + + /** Current streak in qualifying weeks. */ + var streakWeeks: Int = 0, + + /** JSON map of ISO-week-key → makeup completions, e.g. {"2026-W21": 1}. */ + var makeupCompletionsJson: String = "{}", + + /** Comma-separated list of unlocked badge IDs. */ + var unlockedBadges: String = "", +) +``` + +- [ ] **Step 2: Create QuestEntity** + +```kotlin +// app/src/main/java/com/ironlog/app/data/entity/QuestEntity.kt +package com.ironlog.app.data.entity + +import io.objectbox.annotation.Entity +import io.objectbox.annotation.Id + +@Entity +data class QuestEntity( + @Id var id: Long = 0, + + /** Stable quest identifier (UUID). */ + var questId: String = "", + + /** + * Quest type: + * "daily" — resets each day + * "weekly" — resets each ISO week + * "monthly_boss" — monthly dungeon boss + * "makeup" — streak-save bodyweight circuit + */ + var type: String = "daily", + + /** Human-readable description shown in the UI. */ + var description: String = "", + + /** JSON-encoded target payload (exercise names, counts, etc.). */ + var targetJson: String = "{}", + + /** JSON-encoded current progress payload. */ + var currentJson: String = "{}", + + /** XP awarded on completion. */ + var xpReward: Int = 0, + + /** ISO-8601 timestamp when completed, or empty if still active. */ + var completedAt: String = "", + + /** ISO-8601 timestamp when quest expires (blank = no expiry). */ + var expiresAt: String = "", + + /** For monthly_boss: minimum gym sessions required. */ + var gymSessionTarget: Int = 0, + + /** For monthly_boss: comma-separated PR exercises required. */ + var prExercises: String = "", +) +``` + +- [ ] **Step 3: Build to trigger ObjectBox code generation** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +Expected: `BUILD SUCCESSFUL` (ObjectBox processor generates `GamificationProfileEntity_` and `QuestEntity_` box accessors). + +- [ ] **Step 4: Commit** + +``` +git add app/src/main/java/com/ironlog/app/data/entity/GamificationProfileEntity.kt +git add app/src/main/java/com/ironlog/app/data/entity/QuestEntity.kt +git commit -m "feat: add GamificationProfileEntity and QuestEntity ObjectBox schemas" +``` + +--- + +### Task 6: GamificationViewModel — bridge engines to UI state + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/viewmodel/GamificationViewModel.kt` + +- [ ] **Step 1: Implement GamificationViewModel** + +```kotlin +// app/src/main/java/com/ironlog/app/ui/viewmodel/GamificationViewModel.kt +package com.ironlog.app.ui.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.ironlog.app.data.entity.GamificationProfileEntity +import com.ironlog.app.data.entity.GamificationProfileEntity_ +import com.ironlog.app.data.entity.QuestEntity +import com.ironlog.app.data.entity.QuestEntity_ +import com.ironlog.app.domain.gamification.DungeonBossEngine +import com.ironlog.app.domain.gamification.RpgStats +import com.ironlog.app.domain.gamification.StatEngine +import com.ironlog.app.domain.gamification.StreakEngine +import com.ironlog.app.domain.gamification.UserActivityProfile +import com.ironlog.app.domain.gamification.XpAction +import com.ironlog.app.domain.gamification.XpEngine +import com.ironlog.app.ui.model.HistoryEntry +import io.objectbox.BoxStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.util.UUID + +data class GamificationUiState( + val level: Int = 1, + val xpInLevel: Long = 0L, + val xpForNextLevel: Long = 100L, + val rank: String = "E", + val streakWeeks: Int = 0, + val stats: RpgStats = RpgStats(), + val activeTitle: String = "Novice Hunter", + val activeQuests: List = emptyList(), + val unlockedBadges: List = emptyList(), +) + +class GamificationViewModel( + application: Application, + private val boxStore: BoxStore, +) : AndroidViewModel(application) { + + private val xpEngine = XpEngine() + private val statEngine = StatEngine() + private val streakEngine = StreakEngine() + private val dungeonEngine = DungeonBossEngine() + + private val profileBox get() = boxStore.boxFor(GamificationProfileEntity::class.java) + private val questBox get() = boxStore.boxFor(QuestEntity::class.java) + + private val _uiState = MutableStateFlow(GamificationUiState()) + val uiState: StateFlow = _uiState + + init { + loadProfile() + } + + private fun getOrCreateProfile(): GamificationProfileEntity { + return profileBox.query(GamificationProfileEntity_.id.notNull()).build().findFirst() + ?: GamificationProfileEntity(offlineUserId = UUID.randomUUID().toString()) + .also { profileBox.put(it) } + } + + fun loadProfile() { + viewModelScope.launch(Dispatchers.IO) { + val profile = getOrCreateProfile() + val quests = questBox.query(QuestEntity_.completedAt.equal("")).build().find() + val badges = profile.unlockedBadges.split(",").filter { it.isNotBlank() } + _uiState.value = GamificationUiState( + level = profile.level, + xpInLevel = profile.xpInLevel, + xpForNextLevel = xpEngine.xpForLevel(profile.level), + rank = profile.rank, + streakWeeks = profile.streakWeeks, + activeTitle = profile.activeTitle, + activeQuests = quests, + unlockedBadges = badges, + ) + } + } + + /** + * Award XP for an action. Persists to ObjectBox and refreshes UI state. + */ + fun awardXp(action: XpAction) { + viewModelScope.launch(Dispatchers.IO) { + val profile = getOrCreateProfile() + val gained = xpEngine.xpForAction(action) + profile.totalXp += gained + profile.weeklyXp += gained + profile.level = xpEngine.levelFromTotalXp(profile.totalXp) + profile.xpInLevel = xpEngine.xpInCurrentLevel(profile.totalXp) + profile.rank = xpEngine.rankForLevel(profile.level) + profileBox.put(profile) + loadProfile() + } + } + + /** + * Recompute streak, stats, and refresh profile from history. + * Call this after every workout completion. + */ + fun refreshFromHistory(history: List, weeklyGoal: Int) { + viewModelScope.launch(Dispatchers.IO) { + val profile = getOrCreateProfile() + + // Parse makeup completions map + val makeups: Map = runCatching { + Json.decodeFromString>(profile.makeupCompletionsJson) + }.getOrDefault(emptyMap()) + + // Streak + profile.streakWeeks = streakEngine.computeStreakWeeks(history, weeklyGoal, makeups) + + // Stats + val stats = statEngine.compute(history, profile.streakWeeks, history.size) + profile.statsJson = Json.encodeToString(stats) + + profileBox.put(profile) + loadProfile() + } + } + + /** + * Record a completed makeup quest for [isoWeekKey] and award XP. + */ + fun completeMakeupQuest(isoWeekKey: String) { + viewModelScope.launch(Dispatchers.IO) { + val profile = getOrCreateProfile() + val makeups: MutableMap = runCatching { + Json.decodeFromString>(profile.makeupCompletionsJson).toMutableMap() + }.getOrDefault(mutableMapOf()) + makeups[isoWeekKey] = (makeups[isoWeekKey] ?: 0) + 1 + profile.makeupCompletionsJson = Json.encodeToString(makeups as Map) + profileBox.put(profile) + awardXp(XpAction.MAKEUP_QUEST) + } + } + + /** + * Generate and store a monthly dungeon boss quest if none exists for this month. + */ + fun ensureMonthlyBoss(history: List, weeklyGoal: Int) { + viewModelScope.launch(Dispatchers.IO) { + val existing = questBox.query(QuestEntity_.type.equal("monthly_boss")) + .and(QuestEntity_.completedAt.equal("")) + .build().findFirst() + if (existing != null) return@launch + + val makeups: Map = runCatching { + val profile = getOrCreateProfile() + Json.decodeFromString>(profile.makeupCompletionsJson) + }.getOrDefault(emptyMap()) + val streak = streakEngine.computeStreakWeeks(history, weeklyGoal, makeups) + + // Build activity profile from history (last 60 days) + val recentHistory = history.take(30) // approximate + val prCount = recentHistory.sumOf { e -> + e.exercises.sumOf { ex -> if (ex.sets.any { it.weight > 0 }) 1L else 0L }.toInt() + } + val activityProfile = UserActivityProfile( + recentPrCount = prCount, + strongExercises = recentHistory.flatMap { it.exercises }.map { it.name }.distinct().take(5), + avgWeeklyWorkouts = history.size.toDouble() / 8.0, + currentStreak = streak, + ) + val boss = dungeonEngine.generateMonthlyBoss(activityProfile) + val quest = QuestEntity( + questId = UUID.randomUUID().toString(), + type = "monthly_boss", + description = boss.description, + xpReward = boss.xpReward, + gymSessionTarget = boss.gymSessionTarget, + prExercises = boss.prExercises.joinToString(","), + ) + questBox.put(quest) + loadProfile() + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Commit** + +``` +git add app/src/main/java/com/ironlog/app/ui/viewmodel/GamificationViewModel.kt +git commit -m "feat: add GamificationViewModel bridging XP/streak/stat engines to UI state" +``` + +--- + +### Task 7: StatusWindowScreen — RPG character sheet composable + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/StatusWindowScreen.kt` + +- [ ] **Step 1: Implement StatusWindowScreen** + +```kotlin +// app/src/main/java/com/ironlog/app/ui/screens/StatusWindowScreen.kt +package com.ironlog.app.ui.screens + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ironlog.app.domain.gamification.RpgStats +import com.ironlog.app.ui.viewmodel.GamificationUiState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StatusWindowScreen( + state: GamificationUiState, + onBack: () -> Unit, + onMakeupQuestTap: () -> Unit, +) { + val rankColor = rankColor(state.rank) + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Status Window", fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // ── Rank badge + level ──────────────────────────────────────── + item { + RankBadgeSection( + rank = state.rank, + rankColor = rankColor, + level = state.level, + title = state.activeTitle, + ) + } + + // ── XP progress bar ─────────────────────────────────────────── + item { + XpProgressSection( + xpInLevel = state.xpInLevel, + xpForNextLevel = state.xpForNextLevel, + level = state.level, + ) + } + + // ── Streak ──────────────────────────────────────────────────── + item { + StreakSection( + streakWeeks = state.streakWeeks, + onMakeupQuestTap = onMakeupQuestTap, + ) + } + + // ── RPG Stats ───────────────────────────────────────────────── + item { + RpgStatsSection(stats = state.stats) + } + + // ── Active Quests ───────────────────────────────────────────── + if (state.activeQuests.isNotEmpty()) { + item { + Text( + "Active Quests", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + items(state.activeQuests) { quest -> + QuestCard(description = quest.description, xpReward = quest.xpReward) + } + } + + // ── Badge shelf ─────────────────────────────────────────────── + item { + BadgeShelf(unlockedBadges = state.unlockedBadges) + } + + item { Spacer(Modifier.height(24.dp)) } + } + } +} + +@Composable +private fun RankBadgeSection(rank: String, rankColor: Color, level: Int, title: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(100.dp) + .clip(CircleShape) + .background( + Brush.radialGradient(listOf(rankColor.copy(alpha = 0.3f), Color.Transparent)) + ) + .border(3.dp, rankColor, CircleShape), + ) { + Text( + text = rank, + fontSize = 36.sp, + fontWeight = FontWeight.ExtraBold, + color = rankColor, + ) + } + Spacer(Modifier.height(8.dp)) + Text("Level $level", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(title, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun XpProgressSection(xpInLevel: Long, xpForNextLevel: Long, level: Int) { + val fraction = if (xpForNextLevel > 0) (xpInLevel.toFloat() / xpForNextLevel.toFloat()) else 0f + val animatedFraction by animateFloatAsState( + targetValue = fraction.coerceIn(0f, 1f), + animationSpec = tween(800), + label = "xpBar", + ) + Column { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("XP", style = MaterialTheme.typography.labelMedium) + Text("$xpInLevel / $xpForNextLevel", style = MaterialTheme.typography.labelMedium) + } + Spacer(Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { animatedFraction }, + modifier = Modifier.fillMaxWidth().height(10.dp).clip(RoundedCornerShape(5.dp)), + ) + Text( + "Next level: ${level + 1}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.End), + ) + } +} + +@Composable +private fun StreakSection(streakWeeks: Int, onMakeupQuestTap: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("🔥", fontSize = 32.sp) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + "$streakWeeks-week streak", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text("Qualifying weeks at your goal", style = MaterialTheme.typography.bodySmall) + } + TextButton(onClick = onMakeupQuestTap) { + Text("Save streak") + } + } + } +} + +@Composable +private fun RpgStatsSection(stats: RpgStats) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Stats", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + StatRow("STR", stats.str, Color(0xFFFF6B35), "Strength — best 1RM estimate") + StatRow("VIT", stats.vit, Color(0xFF4CAF50), "Vitality — streak + consistency") + StatRow("END", stats.end, Color(0xFF2196F3), "Endurance — average workout duration") + StatRow("AGI", stats.agi, Color(0xFFFFEB3B), "Agility — exercise variety") + StatRow("WIS", stats.wis, Color(0xFF9C27B0), "Wisdom — effort tracking") + StatRow("LUK", stats.luk, Color(0xFFFF9800), "Luck — rare achievements") + } + } +} + +@Composable +private fun StatRow(label: String, value: Int, color: Color, tooltip: String) { + val animatedProgress by animateFloatAsState( + targetValue = (value / 999f).coerceIn(0f, 1f), + animationSpec = tween(600), + label = "stat_$label", + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + label, + fontWeight = FontWeight.Bold, + color = color, + modifier = Modifier.width(40.dp), + style = MaterialTheme.typography.labelLarge, + ) + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.weight(1f).height(8.dp).clip(RoundedCornerShape(4.dp)), + color = color, + ) + Spacer(Modifier.width(8.dp)) + Text( + value.toString(), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.width(36.dp), + textAlign = TextAlign.End, + ) + } +} + +@Composable +private fun QuestCard(description: String, xpReward: Int) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text(description, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(4.dp)) + Text("Reward: +$xpReward XP", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)) + } + } +} + +@Composable +private fun BadgeShelf(unlockedBadges: List) { + if (unlockedBadges.isEmpty()) return + Column { + Text("Badges", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + // Placeholder grid — replace badge items with actual drawable resources when art is ready + androidx.compose.foundation.lazy.LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(unlockedBadges) { badge -> + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(56.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Text(badge.take(2).uppercase(), style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold) + } + } + } + } + } +} + +private fun rankColor(rank: String): Color = when (rank) { + "E" -> Color(0xFF9E9E9E) + "D" -> Color(0xFF4CAF50) + "C" -> Color(0xFF2196F3) + "B" -> Color(0xFF9C27B0) + "A" -> Color(0xFFFF9800) + "S" -> Color(0xFFFF5722) + "National" -> Color(0xFFFFD700) + else -> Color(0xFF9E9E9E) +} +``` + +- [ ] **Step 2: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Commit** + +``` +git add app/src/main/java/com/ironlog/app/ui/screens/StatusWindowScreen.kt +git commit -m "feat: add StatusWindowScreen RPG character sheet with rank/stats/quests/badges" +``` + +--- + +### Task 8: MakeUpQuestSheet — bodyweight streak-save circuit + +**Files:** +- Create: `app/src/main/java/com/ironlog/app/ui/screens/MakeUpQuestSheet.kt` + +- [ ] **Step 1: Implement MakeUpQuestSheet** + +```kotlin +// app/src/main/java/com/ironlog/app/ui/screens/MakeUpQuestSheet.kt +package com.ironlog.app.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +data class MakeUpCircuit( + val id: String, + val name: String, + val category: String, // "Push", "Pull", "Core", "Full Body" + val exercises: List, + val instructions: String, +) + +private val CIRCUITS = listOf( + MakeUpCircuit( + id = "push_basic", + name = "Push Circuit", + category = "Push", + exercises = listOf("20 Push-ups", "15 Tricep Dips (chair)", "10 Pike Push-ups"), + instructions = "3 rounds, 60 sec rest between rounds.", + ), + MakeUpCircuit( + id = "pull_basic", + name = "Pull Circuit", + category = "Pull", + exercises = listOf("10 Pull-ups (or 15 Inverted Rows)", "12 Chin-ups", "20 Band Pull-Aparts"), + instructions = "3 rounds, 90 sec rest between rounds.", + ), + MakeUpCircuit( + id = "core_basic", + name = "Core Circuit", + category = "Core", + exercises = listOf("30 Crunches", "20 Leg Raises", "60 sec Plank", "20 Russian Twists"), + instructions = "3 rounds, 45 sec rest between rounds.", + ), + MakeUpCircuit( + id = "fullbody_basic", + name = "Full Body Circuit", + category = "Full Body", + exercises = listOf("20 Burpees", "20 Squats", "15 Push-ups", "10 Pull-ups", "30 sec Plank"), + instructions = "4 rounds, 90 sec rest between rounds.", + ), +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MakeUpQuestSheet( + onDismiss: () -> Unit, + onComplete: (circuitId: String) -> Unit, +) { + var selected: MakeUpCircuit? by remember { mutableStateOf(null) } + var confirmed by remember { mutableStateOf(false) } + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "⚡ Streak Save Quest", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + "Complete one bodyweight circuit to protect your streak this week.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (!confirmed) { + CIRCUITS.forEach { circuit -> + val isSelected = selected?.id == circuit.id + OutlinedCard( + onClick = { selected = circuit }, + modifier = Modifier.fillMaxWidth(), + border = if (isSelected) CardDefaults.outlinedCardBorder() else null, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(circuit.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text(circuit.category, style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary) + } + Spacer(Modifier.height(4.dp)) + circuit.exercises.forEach { ex -> + Text("• $ex", style = MaterialTheme.typography.bodySmall) + } + } + } + } + + Button( + onClick = { if (selected != null) confirmed = true }, + enabled = selected != null, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Start Circuit") + } + } else { + val circuit = selected!! + Text("Complete this circuit:", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + circuit.exercises.forEach { ex -> + Text("✓ $ex", style = MaterialTheme.typography.bodyMedium) + } + Text(circuit.instructions, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { onComplete(circuit.id) }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary), + ) { + Text("✅ I Completed It — Save My Streak! (+25 XP)") + } + + TextButton(onClick = { confirmed = false }, modifier = Modifier.fillMaxWidth()) { + Text("Choose a different circuit") + } + } + } + } +} +``` + +- [ ] **Step 2: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 5 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Commit** + +``` +git add app/src/main/java/com/ironlog/app/ui/screens/MakeUpQuestSheet.kt +git commit -m "feat: add MakeUpQuestSheet bodyweight circuit bottom sheet for streak save" +``` + +--- + +### Task 9: Navigation wiring — add statusWindow route + +**Files:** +- Modify: `app/src/main/java/com/ironlog/app/ui/navigation/AppNavigation.kt` + +- [ ] **Step 1: Read AppNavigation.kt to find the NavHost composable** + +Open `app/src/main/java/com/ironlog/app/ui/navigation/AppNavigation.kt` and locate the `NavHost` block and existing route strings. + +- [ ] **Step 2: Add the statusWindow route** + +Inside the `NavHost` block, add: + +```kotlin +composable("statusWindow") { + // GamificationViewModel must be provided by the parent + // Use hiltViewModel() if Hilt is available, or pass via CompositionLocal + val gamificationVm: GamificationViewModel = viewModel( + factory = GamificationViewModelFactory(LocalContext.current.applicationContext as Application, boxStore) + ) + val gamState by gamificationVm.uiState.collectAsState() + var showMakeupSheet by remember { mutableStateOf(false) } + + StatusWindowScreen( + state = gamState, + onBack = { navController.popBackStack() }, + onMakeupQuestTap = { showMakeupSheet = true }, + ) + + if (showMakeupSheet) { + MakeUpQuestSheet( + onDismiss = { showMakeupSheet = false }, + onComplete = { circuitId -> + showMakeupSheet = false + // Compute current ISO week key and record makeup + val weekKey = java.time.LocalDate.now().let { + val week = it.get(java.time.temporal.WeekFields.ISO.weekOfWeekBasedYear()) + val year = it.get(java.time.temporal.WeekFields.ISO.weekBasedYear()) + "$year-W${week.toString().padStart(2, '0')}" + } + gamificationVm.completeMakeupQuest(weekKey) + }, + ) + } +} +``` + +Add required imports at the top of AppNavigation.kt: +```kotlin +import com.ironlog.app.ui.screens.StatusWindowScreen +import com.ironlog.app.ui.screens.MakeUpQuestSheet +import com.ironlog.app.ui.viewmodel.GamificationViewModel +``` + +- [ ] **Step 3: Add entry point from HomeScreen or profile section** + +In `HomeScreen.kt`, add a button/icon that navigates to `"statusWindow"`: + +```kotlin +// In the top app bar actions or profile icon area: +IconButton(onClick = { navController.navigate("statusWindow") }) { + // Placeholder: use a shield or person icon + Icon(Icons.Default.Shield, contentDescription = "Status Window") +} +``` + +- [ ] **Step 4: Build** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 10 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 5: Commit** + +``` +git add app/src/main/java/com/ironlog/app/ui/navigation/AppNavigation.kt +git add app/src/main/java/com/ironlog/app/ui/screens/HomeScreen.kt +git commit -m "feat: wire StatusWindowScreen into navigation with makeup quest sheet" +``` + +--- + +### Task 10: XP bar on HomeScreen + +**Files:** +- Modify: `app/src/main/java/com/ironlog/app/ui/screens/HomeScreen.kt` + +- [ ] **Step 1: Add compact XP bar and streak badge to HomeScreen** + +In the HomeScreen's top section (above the day card list), add: + +```kotlin +// Compact XP + streak bar at top of HomeScreen +// (gamificationVm should be obtained the same way as in AppNavigation) +val gamState by gamificationVm.uiState.collectAsState() + +Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, +) { + // Rank chip + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + text = "${gamState.rank} Lv.${gamState.level}", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + } + + // XP bar (compact) + val xpFraction = if (gamState.xpForNextLevel > 0) + (gamState.xpInLevel.toFloat() / gamState.xpForNextLevel).coerceIn(0f, 1f) + else 0f + val animXp by animateFloatAsState(xpFraction, tween(600), label = "homeXpBar") + Box( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp) + .height(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(animXp) + .background(MaterialTheme.colorScheme.primary), + ) + } + + // Streak badge + Surface( + shape = RoundedCornerShape(12.dp), + color = if (gamState.streakWeeks > 0) + MaterialTheme.colorScheme.tertiaryContainer + else MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = "🔥 ${gamState.streakWeeks}w", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + } +} +``` + +- [ ] **Step 2: Build and verify** + +``` +.\gradlew :app:assembleDebug 2>&1 | Select-String "error:|BUILD" | Select-Object -Last 5 +``` + +- [ ] **Step 3: Commit** + +``` +git add app/src/main/java/com/ironlog/app/ui/screens/HomeScreen.kt +git commit -m "feat: show compact XP bar and streak badge on HomeScreen" +``` + +--- + +## Badge Art Prompt (for AI image generation) + +Generate badge art with your preferred tool using this prompt: + +> "Create a set of 7 circular badge icons for a fitness RPG app with Solo Leveling dark fantasy aesthetic. Each badge is 512×512px with a transparent background. Badges should be dark, dramatic, and cinematic — think dungeon hunter crests. Rank E: gray stone shield. Rank D: green iron fist. Rank C: blue crystal sword. Rank B: purple arcane rune. Rank A: orange fire emblem. Rank S: red dragon sigil. Rank National: gold crown with wings. Style: metallic, glowing edges, dark background. No text on the badges." + +Save the generated images as `app/src/main/res/drawable/badge_rank_e.webp`, `badge_rank_d.webp`, etc., and replace the Text placeholders in `BadgeShelf` with `Image(painterResource(...))`. + +--- + +## Self-Review + +**Spec coverage:** +- ✅ XP system with level curve and rank progression (E→National) +- ✅ Streak engine: weekly-goal-based, not daily-consecutive +- ✅ Bodyweight make-up quests save streak (goal-1 + 1 makeup = qualifies) +- ✅ Adaptive dungeon boss quest based on PR history and activity profile +- ✅ Six RPG stats derived from real workout data +- ✅ Status Window screen with rank badge, XP bar, stats, quests, badges +- ✅ HomeScreen XP bar + streak badge +- ✅ MakeUpQuestSheet with 4 circuit categories +- ✅ All engines are pure-Kotlin, fully unit-tested +- ✅ ObjectBox entities for persistence +- ✅ Badge art prompt provided + +**Type consistency:** `RpgStats` defined in `StatEngine.kt`, used in `GamificationUiState` and `StatusWindowScreen`. `XpAction` enum defined in `XpEngine.kt`, referenced in `GamificationViewModel`. `UserActivityProfile` defined in `DungeonBossEngine.kt`, used in `GamificationViewModel.ensureMonthlyBoss`. diff --git a/docs/superpowers/plans/2026-05-27-watermelon-rename-reorganize.md b/docs/superpowers/plans/2026-05-27-watermelon-rename-reorganize.md new file mode 100644 index 0000000..36db923 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-watermelon-rename-reorganize.md @@ -0,0 +1,494 @@ +# Watermelon Rename + Reorganize Implementation Plan + +> **Status:** Historical execution plan. The rename/reorganization was committed, but these procedural checkboxes were never backfilled. They are not a current backlog; use `AGENTS.md` and Git history for status. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate all WatermelonDB-derived names from the Kotlin codebase and reorganize `ui/screens/` into feature subdirectories. + +**Architecture:** Pure mechanical refactoring — rename files/classes, update package declarations, update imports. No logic changes. The build is the test: `BUILD SUCCESSFUL` = done. Tasks ordered from zero-cascade (no import ripple) to maximum-cascade (screens split) to reduce compound failure risk. + +**Tech Stack:** Kotlin, Jetpack Compose, ObjectBox 4.0.3, Gradle. Build: `.\gradlew.bat assembleDebug --no-daemon` in `Z:\KOTLIN\UnifiedPort`. + +--- + +## File Map + +**Renamed (file + class name):** +- `ui/viewmodel/WatermelonAppDataViewModel.kt` → `AppDataViewModel.kt` (class `WatermelonAppDataViewModel` → `AppDataViewModel`) +- `ui/viewmodel/WatermelonStatsViewModel.kt` → `StatsViewModel.kt` (class → `StatsViewModel`) +- `ui/viewmodel/WatermelonPlansViewModel.kt` → `PlansViewModel.kt` (class → `PlansViewModel`) +- `ui/viewmodel/WatermelonBodyMeasurementsViewModel.kt` → `BodyMeasurementsViewModel.kt` (classes → `BodyMeasurementsViewModel`, `BodyMeasurementsViewModelFactory`) + +**Renamed (file only):** +- `ui/state/WorkoutContextPort.kt` → `WorkoutState.kt` (classes inside unchanged) + +**Internal renames only (no file rename):** +- `data/repository/ImportExportRepository.kt`: private functions + internal string literal + +**Moved (physical dir change only — package already correct):** +- `data/entity/AthleteCalibrationEntity.kt` → `data/objectbox/` +- `data/entity/GamificationProfileEntity.kt` → `data/objectbox/` +- `data/entity/IronLedgerEventEntity.kt` → `data/objectbox/` + +**Moved (file + package change):** +- `data/exercise/ExerciseTrackingTypeNormalizer.kt` → `util/ExerciseTrackingTypeNormalizer.kt` (package: `data.exercise` → `util`) + +**Moved (file + package change) — 39 screen files into 9 subdirs:** +- `ui/screens/*.kt` files distributed into `home/`, `workout/`, `plans/`, `body/`, `stats/`, `recovery/`, `intelligence/`, `gamification/`, `settings/` + +**Import updates required:** +- 13 screens + `AppNavigator.kt` + `IronLogApp.kt` → new ViewModel class names +- `ExerciseRepository.kt`, `ExerciseSeed.kt` → new util package for Normalizer +- `AppNavigator.kt` + `IronLogApp.kt` → new screen packages after split + +--- + +## Task 1: Internal renames in ImportExportRepository.kt (zero cascade) + +**Files:** +- Modify: `app/src/main/java/com/ironlog/app/data/repository/ImportExportRepository.kt` + +- [ ] **Step 1: Rename private function `isWatermelonExport` → `isIronLogExport`** + + In `ImportExportRepository.kt`, find line ~197: + ```kotlin + private fun isWatermelonExport(payload: JSONObject): Boolean { + ``` + Change to: + ```kotlin + private fun isIronLogExport(payload: JSONObject): Boolean { + ``` + Also update its call site at line ~207: + ```kotlin + if (isIronLogExport(payload)) return "ironlog_v1" + ``` + +- [ ] **Step 2: Rename internal format tag `"watermelon_v1"` → `"ironlog_v1"`** + + Find ~line 316: + ```kotlin + if (format != "watermelon_v1") { + ``` + Change to: + ```kotlin + if (format != "ironlog_v1") { + ``` + +- [ ] **Step 3: Rename `convertLegacyToWatermelonExport` → `convertLegacyToIronLogExport`** + + Find ~line 317 (call site), ~line 403 (call site), ~line 406 (declaration): + ```kotlin + // line ~317 + val conv = convertLegacyToIronLogExport(raw) + // line ~403 + return convertLegacyToIronLogExport(raw) + // line ~406 + private fun convertLegacyToIronLogExport(raw: JSONObject): JSONObject { + ``` + +- [ ] **Step 4: Confirm EXPORT_TYPE is untouched** + + Run: + ``` + grep "ironlog_watermelon_export" app\src\main\java\com\ironlog\app\data\repository\ImportExportRepository.kt + ``` + Expected: line with `const val EXPORT_TYPE = "ironlog_watermelon_export"` still present. + +- [ ] **Step 5: Commit** + ```bash + git add app/src/main/java/com/ironlog/app/data/repository/ImportExportRepository.kt + git commit -m "refactor: rename watermelon internal functions in ImportExportRepository" + ``` + +--- + +## Task 2: Comment cleanup (zero cascade) + +**Files:** +- Modify: `data/objectbox/Entities.kt` +- Modify: `data/objectbox/ObjectBox.kt` +- Modify: `data/repository/BodyMeasurementRepository.kt` +- Modify: `ui/model/UiModels.kt` + +- [ ] **Step 1: Update entity comments in Entities.kt** + + Replace every `/** Watermelon table: X */` with `/** ObjectBox entity: X */` (there is one per entity class). + +- [ ] **Step 2: Update ObjectBox.kt comment** + + Find the comment referencing "WatermelonDB database.js equivalent" and reword to: `/** ObjectBox store — persistent on-device database for all IronLog entities. */` + +- [ ] **Step 3: Update BodyMeasurementRepository.kt comment** + + Find the comment referencing WatermelonDB observe() and reword to describe ObjectBox flow (e.g. `// Emits updates via ObjectBox reactive query`). + +- [ ] **Step 4: Update UiModels.kt comment** + + Find the comment referencing React screens and Watermelon rows and replace with a brief description of the Kotlin/Compose model. + +- [ ] **Step 5: Commit** + ```bash + git add app/src/main/java/com/ironlog/app/data/objectbox/Entities.kt + git add app/src/main/java/com/ironlog/app/data/objectbox/ObjectBox.kt + git add app/src/main/java/com/ironlog/app/data/repository/BodyMeasurementRepository.kt + git add app/src/main/java/com/ironlog/app/ui/model/UiModels.kt + git commit -m "refactor: remove WatermelonDB comments, replace with ObjectBox equivalents" + ``` + +--- + +## Task 3: Rename WorkoutContextPort.kt → WorkoutState.kt (file only, zero import cascade) + +**Files:** +- Rename: `ui/state/WorkoutContextPort.kt` → `ui/state/WorkoutState.kt` + +No class names inside change. Kotlin imports reference classes, not filenames — zero callers need updating. + +- [ ] **Step 1: Rename the file** + ```powershell + Rename-Item "app\src\main\java\com\ironlog\app\ui\state\WorkoutContextPort.kt" "WorkoutState.kt" + ``` + +- [ ] **Step 2: Commit** + ```bash + git add -A app/src/main/java/com/ironlog/app/ui/state/ + git commit -m "refactor: rename WorkoutContextPort.kt to WorkoutState.kt" + ``` + +--- + +## Task 4: ViewModel class renames + caller updates (13 screens + AppNavigator) + +**Files to modify (callers — class name substitution only):** +- `navigation/AppNavigator.kt` +- `ui/screens/AIPlanScreen.kt` +- `ui/screens/ActiveWorkoutScreen.kt` +- `ui/screens/HistoryScreen.kt` +- `ui/screens/HomeScreen.kt` +- `ui/screens/PlanEditorScreen.kt` +- `ui/screens/PlansScreen.kt` +- `ui/screens/ProgramInsightsScreen.kt` +- `ui/screens/RecoveryMapScreen.kt` +- `ui/screens/SettingsScreen.kt` +- `ui/screens/StatsScreen.kt` +- `ui/screens/TrainingIntelligenceScreen.kt` +- `ui/screens/VolumeAnalyticsScreen.kt` + +**Files to rename + edit (ViewModel sources):** +- `ui/viewmodel/WatermelonAppDataViewModel.kt` → `AppDataViewModel.kt` +- `ui/viewmodel/WatermelonStatsViewModel.kt` → `StatsViewModel.kt` +- `ui/viewmodel/WatermelonPlansViewModel.kt` → `PlansViewModel.kt` +- `ui/viewmodel/WatermelonBodyMeasurementsViewModel.kt` → `BodyMeasurementsViewModel.kt` + +- [ ] **Step 1: Mass-replace class names across all caller files** + + Run these four replacements (sed-style, across all `.kt` files): + ``` + WatermelonAppDataViewModel → AppDataViewModel + WatermelonStatsViewModel → StatsViewModel + WatermelonPlansViewModel → PlansViewModel + WatermelonBodyMeasurementsViewModel → BodyMeasurementsViewModel + WatermelonBodyMeasurementsViewModelFactory → BodyMeasurementsViewModelFactory + ``` + This updates both import lines and usage sites. The ViewModel source files get updated too (class declarations inside them). + +- [ ] **Step 2: Rename the four ViewModel source files** + ```powershell + $base = "app\src\main\java\com\ironlog\app\ui\viewmodel" + Rename-Item "$base\WatermelonAppDataViewModel.kt" "AppDataViewModel.kt" + Rename-Item "$base\WatermelonStatsViewModel.kt" "StatsViewModel.kt" + Rename-Item "$base\WatermelonPlansViewModel.kt" "PlansViewModel.kt" + Rename-Item "$base\WatermelonBodyMeasurementsViewModel.kt" "BodyMeasurementsViewModel.kt" + ``` + +- [ ] **Step 3: Remove old "Kotlin replacement for useWatermelonXxx.js" class-level comments** + + In each ViewModel file, remove or replace the top-level class doc comment. Replace with a one-line summary of actual purpose: + - `AppDataViewModel` → `/** Aggregates plans, history, settings, and personal bests for the entire app. */` + - `StatsViewModel` → `/** Provides statistics and analytics data for the Stats screen. */` + - `PlansViewModel` → `/** Manages training plan data including CRUD and program scheduling. */` + - `BodyMeasurementsViewModel` → `/** Tracks body measurement entries and history. */` + +- [ ] **Step 4: Build check** + ``` + cd Z:\KOTLIN\UnifiedPort && .\gradlew.bat assembleDebug --no-daemon 2>&1 | tail -5 + ``` + Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 5: Commit** + ```bash + git add -A app/src/main/java/com/ironlog/app/ui/viewmodel/ + git add app/src/main/java/com/ironlog/app/navigation/AppNavigator.kt + git add app/src/main/java/com/ironlog/app/ui/screens/ + git commit -m "refactor: rename Watermelon ViewModels to AppDataViewModel, StatsViewModel, PlansViewModel, BodyMeasurementsViewModel" + ``` + +--- + +## Task 5: Entity file consolidation (physical move only — packages already correct) + +**Files:** +- Move: `data/entity/AthleteCalibrationEntity.kt` → `data/objectbox/` +- Move: `data/entity/GamificationProfileEntity.kt` → `data/objectbox/` +- Move: `data/entity/IronLedgerEventEntity.kt` → `data/objectbox/` +- Delete: `data/entity/` (empty after move) + +All three entity files already declare `package com.ironlog.app.data.objectbox`. All callers already import from `com.ironlog.app.data.objectbox`. This is a physical directory move with zero code changes. + +- [ ] **Step 1: Move files** + ```powershell + $src = "app\src\main\java\com\ironlog\app\data\entity" + $dst = "app\src\main\java\com\ironlog\app\data\objectbox" + Move-Item "$src\AthleteCalibrationEntity.kt" $dst + Move-Item "$src\GamificationProfileEntity.kt" $dst + Move-Item "$src\IronLedgerEventEntity.kt" $dst + Remove-Item $src + ``` + +- [ ] **Step 2: Commit** + ```bash + git add -A app/src/main/java/com/ironlog/app/data/ + git commit -m "refactor: consolidate data/entity/ files into data/objectbox/ (packages were already identical)" + ``` + +--- + +## Task 6: Relocate ExerciseTrackingTypeNormalizer to util/ + +**Files:** +- Move: `data/exercise/ExerciseTrackingTypeNormalizer.kt` → `util/ExerciseTrackingTypeNormalizer.kt` +- Modify: `data/repository/ExerciseRepository.kt` (import line) +- Modify: `data/seed/ExerciseSeed.kt` (import line) + +- [ ] **Step 1: Update package declaration in the file** + + In `data/exercise/ExerciseTrackingTypeNormalizer.kt`, change line 1: + ```kotlin + package com.ironlog.app.util + ``` + +- [ ] **Step 2: Move file** + ```powershell + Move-Item "app\src\main\java\com\ironlog\app\data\exercise\ExerciseTrackingTypeNormalizer.kt" ` + "app\src\main\java\com\ironlog\app\util\ExerciseTrackingTypeNormalizer.kt" + Remove-Item "app\src\main\java\com\ironlog\app\data\exercise" + ``` + +- [ ] **Step 3: Update import in ExerciseRepository.kt** + + Change: + ```kotlin + import com.ironlog.app.data.exercise.ExerciseTrackingTypeNormalizer + ``` + To: + ```kotlin + import com.ironlog.app.util.ExerciseTrackingTypeNormalizer + ``` + +- [ ] **Step 4: Update import in ExerciseSeed.kt** + + Change: + ```kotlin + import com.ironlog.app.data.exercise.ExerciseTrackingTypeNormalizer + ``` + To: + ```kotlin + import com.ironlog.app.util.ExerciseTrackingTypeNormalizer + ``` + +- [ ] **Step 5: Build check** + ``` + cd Z:\KOTLIN\UnifiedPort && .\gradlew.bat assembleDebug --no-daemon 2>&1 | tail -5 + ``` + Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 6: Commit** + ```bash + git add -A app/src/main/java/com/ironlog/app/data/exercise/ + git add app/src/main/java/com/ironlog/app/util/ExerciseTrackingTypeNormalizer.kt + git add app/src/main/java/com/ironlog/app/data/repository/ExerciseRepository.kt + git add app/src/main/java/com/ironlog/app/data/seed/ExerciseSeed.kt + git commit -m "refactor: move ExerciseTrackingTypeNormalizer from data/exercise/ to util/" + ``` + +--- + +## Task 7: ui/screens/ → feature subdirectories + +**Directory mapping:** + +| Subdir | Files | +|--------|-------| +| `home/` | `HomeScreen.kt` | +| `workout/` | `ActiveWorkoutScreen.kt`, `WorkoutCalendarScreen.kt`, `WorkoutExerciseBinding.kt` | +| `plans/` | `PlansScreen.kt`, `PlanEditorScreen.kt`, `ProgramPickerScreen.kt`, `ProgramInsightsScreen.kt`, `AIPlanScreen.kt`, `PlanQrScanScreen.kt`, `PlanQrShareSheet.kt` | +| `body/` | `BodyWeightScreen.kt`, `BodyMeasurementsScreen.kt`, `BodyMapCanvas.kt`, `ProgressPhotosScreen.kt` | +| `stats/` | `StatsScreen.kt`, `HistoryScreen.kt`, `ExerciseProgressScreen.kt`, `VolumeAnalyticsScreen.kt` | +| `recovery/` | `RecoveryMapScreen.kt`, `RecoveryHeatmapCard.kt`, `RecoveryCircuitSheet.kt` | +| `intelligence/` | `TrainingIntelligenceScreen.kt`, `ApexEngineCard.kt`, `CloudAiCard.kt` | +| `gamification/` | `StatusWindowScreen.kt` | +| `settings/` | `SettingsScreen.kt`, `GymProfilesScreen.kt`, `GymProfileEditorScreen.kt`, `CreateExerciseScreen.kt`, `ExerciseLibraryScreen.kt`, `DataPortabilityScreen.kt`, `ImportCenterScreen.kt`, `BackupCenterScreen.kt`, `RestoreDataScreen.kt`, `HealthConnectScreen.kt`, `HealthConnectPermissionSheet.kt`, `PrivacyScreen.kt` | +| *(root)* | `SplashScreen.kt`, `OnboardingScreen.kt` — stay at `ui/screens/` root | +| `onboarding/` | Already organized — leave untouched | + +**Package change:** every moved file's `package com.ironlog.app.ui.screens` → `com.ironlog.app.ui.screens.`. + +**Import update required in:** `navigation/AppNavigator.kt`, `ui/IronLogApp.kt`, `ui/screens/ActiveWorkoutScreen.kt` (imports `WorkoutExerciseBinding`). + +- [ ] **Step 1: Create subdirectories** + ```powershell + $base = "app\src\main\java\com\ironlog\app\ui\screens" + "home","workout","plans","body","stats","recovery","intelligence","gamification","settings" | ForEach-Object { New-Item -ItemType Directory "$base\$_" -Force } + ``` + +- [ ] **Step 2: Move files into subdirs** + ```powershell + $s = "app\src\main\java\com\ironlog\app\ui\screens" + # home + Move-Item "$s\HomeScreen.kt" "$s\home\" + # workout + Move-Item "$s\ActiveWorkoutScreen.kt","$s\WorkoutCalendarScreen.kt","$s\WorkoutExerciseBinding.kt" "$s\workout\" + # plans + Move-Item "$s\PlansScreen.kt","$s\PlanEditorScreen.kt","$s\ProgramPickerScreen.kt","$s\ProgramInsightsScreen.kt","$s\AIPlanScreen.kt","$s\PlanQrScanScreen.kt","$s\PlanQrShareSheet.kt" "$s\plans\" + # body + Move-Item "$s\BodyWeightScreen.kt","$s\BodyMeasurementsScreen.kt","$s\BodyMapCanvas.kt","$s\ProgressPhotosScreen.kt" "$s\body\" + # stats + Move-Item "$s\StatsScreen.kt","$s\HistoryScreen.kt","$s\ExerciseProgressScreen.kt","$s\VolumeAnalyticsScreen.kt" "$s\stats\" + # recovery + Move-Item "$s\RecoveryMapScreen.kt","$s\RecoveryHeatmapCard.kt","$s\RecoveryCircuitSheet.kt" "$s\recovery\" + # intelligence + Move-Item "$s\TrainingIntelligenceScreen.kt","$s\ApexEngineCard.kt","$s\CloudAiCard.kt" "$s\intelligence\" + # gamification + Move-Item "$s\StatusWindowScreen.kt" "$s\gamification\" + # settings + Move-Item "$s\SettingsScreen.kt","$s\GymProfilesScreen.kt","$s\GymProfileEditorScreen.kt","$s\CreateExerciseScreen.kt","$s\ExerciseLibraryScreen.kt","$s\DataPortabilityScreen.kt","$s\ImportCenterScreen.kt","$s\BackupCenterScreen.kt","$s\RestoreDataScreen.kt","$s\HealthConnectScreen.kt","$s\HealthConnectPermissionSheet.kt","$s\PrivacyScreen.kt" "$s\settings\" + ``` + +- [ ] **Step 3: Update package declarations in all moved files** + + For each subdir, change `package com.ironlog.app.ui.screens` to `package com.ironlog.app.ui.screens.` in every file in that subdir. Do not touch `SplashScreen.kt`, `OnboardingScreen.kt`, or anything in `onboarding/`. + +- [ ] **Step 4: Update imports in AppNavigator.kt** + + `AppNavigator.kt` imports screen composables by class. Add granular imports for each new package: + ```kotlin + import com.ironlog.app.ui.screens.home.HomeScreen + import com.ironlog.app.ui.screens.workout.ActiveWorkoutScreen + import com.ironlog.app.ui.screens.workout.WorkoutCalendarScreen + import com.ironlog.app.ui.screens.plans.PlansScreen + import com.ironlog.app.ui.screens.plans.PlanEditorScreen + import com.ironlog.app.ui.screens.plans.ProgramPickerScreen + import com.ironlog.app.ui.screens.plans.ProgramInsightsScreen + import com.ironlog.app.ui.screens.plans.AIPlanScreen + import com.ironlog.app.ui.screens.plans.PlanQrScanScreen + import com.ironlog.app.ui.screens.plans.PlanQrShareSheet + import com.ironlog.app.ui.screens.body.BodyWeightScreen + import com.ironlog.app.ui.screens.body.BodyMeasurementsScreen + import com.ironlog.app.ui.screens.body.BodyMapCanvas + import com.ironlog.app.ui.screens.body.ProgressPhotosScreen + import com.ironlog.app.ui.screens.stats.StatsScreen + import com.ironlog.app.ui.screens.stats.HistoryScreen + import com.ironlog.app.ui.screens.stats.ExerciseProgressScreen + import com.ironlog.app.ui.screens.stats.VolumeAnalyticsScreen + import com.ironlog.app.ui.screens.recovery.RecoveryMapScreen + import com.ironlog.app.ui.screens.recovery.RecoveryHeatmapCard + import com.ironlog.app.ui.screens.recovery.RecoveryCircuitSheet + import com.ironlog.app.ui.screens.intelligence.TrainingIntelligenceScreen + import com.ironlog.app.ui.screens.intelligence.ApexEngineCard + import com.ironlog.app.ui.screens.intelligence.CloudAiCard + import com.ironlog.app.ui.screens.gamification.StatusWindowScreen + import com.ironlog.app.ui.screens.settings.SettingsScreen + import com.ironlog.app.ui.screens.settings.GymProfilesScreen + import com.ironlog.app.ui.screens.settings.GymProfileEditorScreen + import com.ironlog.app.ui.screens.settings.CreateExerciseScreen + import com.ironlog.app.ui.screens.settings.ExerciseLibraryScreen + import com.ironlog.app.ui.screens.settings.DataPortabilityScreen + import com.ironlog.app.ui.screens.settings.ImportCenterScreen + import com.ironlog.app.ui.screens.settings.BackupCenterScreen + import com.ironlog.app.ui.screens.settings.RestoreDataScreen + import com.ironlog.app.ui.screens.settings.HealthConnectScreen + import com.ironlog.app.ui.screens.settings.HealthConnectPermissionSheet + import com.ironlog.app.ui.screens.settings.PrivacyScreen + ``` + Remove any wildcard `import com.ironlog.app.ui.screens.*` if present. + +- [ ] **Step 5: Update imports in IronLogApp.kt** + + Same pattern — replace `com.ironlog.app.ui.screens.XxxScreen` with the new package path for each screen referenced. + +- [ ] **Step 6: Update intra-screen imports** + + `ui/screens/workout/ActiveWorkoutScreen.kt` imports `WorkoutExerciseBinding` — update to: + ```kotlin + import com.ironlog.app.ui.screens.workout.WorkoutExerciseBinding + ``` + (Same package now, so this import may become unnecessary — check if they're in the same file or separate.) + +- [ ] **Step 7: Build check** + ``` + cd Z:\KOTLIN\UnifiedPort && .\gradlew.bat assembleDebug --no-daemon 2>&1 | tail -20 + ``` + Expected: `BUILD SUCCESSFUL`. If errors, read error lines and fix missing imports one by one. + +- [ ] **Step 8: Commit** + ```bash + git add -A app/src/main/java/com/ironlog/app/ui/screens/ + git add app/src/main/java/com/ironlog/app/navigation/AppNavigator.kt + git add app/src/main/java/com/ironlog/app/ui/IronLogApp.kt + git commit -m "refactor: split ui/screens/ into feature subdirectories (home/workout/plans/body/stats/recovery/intelligence/gamification/settings)" + ``` + +--- + +## Task 8: Final verification + AGENTS.md update + +- [ ] **Step 1: Full build** + ``` + cd Z:\KOTLIN\UnifiedPort && .\gradlew.bat assembleDebug --no-daemon + ``` + Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 2: Verify no Watermelon class names remain** + ``` + grep -r "WatermelonAppDataViewModel\|WatermelonStatsViewModel\|WatermelonPlansViewModel\|WatermelonBodyMeasurementsViewModel\|WatermelonBodyMeasurementsViewModelFactory" app/src --include="*.kt" + ``` + Expected: 0 results. + +- [ ] **Step 3: Verify EXPORT_TYPE preserved** + ``` + grep "ironlog_watermelon_export" app/src/main/java/com/ironlog/app/data/repository/ImportExportRepository.kt + ``` + Expected: line with `const val EXPORT_TYPE = "ironlog_watermelon_export"` present. + +- [ ] **Step 4: Update AGENTS.md** + + Append entry to `Z:\KOTLIN\AGENTS.md`: + ``` + ## Watermelon Rename + Reorganize — 2026-05-27 + + Eliminated all WatermelonDB-derived names and reorganized ui/screens/ into feature subdirectories. + + **Renames:** + - WatermelonAppDataViewModel → AppDataViewModel (file + class) + - WatermelonStatsViewModel → StatsViewModel (file + class) + - WatermelonPlansViewModel → PlansViewModel (file + class) + - WatermelonBodyMeasurementsViewModel → BodyMeasurementsViewModel (file + class + Factory) + - WorkoutContextPort.kt → WorkoutState.kt (file only) + - ImportExportRepository: isWatermelonExport→isIronLogExport, convertLegacyToWatermelonExport→convertLegacyToIronLogExport, "watermelon_v1"→"ironlog_v1" + - Comments: "Watermelon table:" → "ObjectBox entity:" across Entities.kt, ObjectBox.kt, BodyMeasurementRepository.kt, UiModels.kt + - EXPORT_TYPE string value "ironlog_watermelon_export" PRESERVED (backward compat) + + **Reorganized:** + - ui/screens/ split into 9 feature subdirs: home/workout/plans/body/stats/recovery/intelligence/gamification/settings + - data/entity/ (3 files) physically moved into data/objectbox/ (packages were already com.ironlog.app.data.objectbox) + - data/exercise/ExerciseTrackingTypeNormalizer.kt moved to util/ + + **Build:** `.\gradlew.bat assembleDebug --no-daemon` → BUILD SUCCESSFUL + ``` + +- [ ] **Step 5: Final commit** + ```bash + git add Z:/KOTLIN/AGENTS.md + git commit -m "docs: AGENTS.md — watermelon rename + reorganize complete" + ``` diff --git a/docs/superpowers/plans/2026-06-07-fix-codex-uncommitted-work.md b/docs/superpowers/plans/2026-06-07-fix-codex-uncommitted-work.md new file mode 100644 index 0000000..2d8a082 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-fix-codex-uncommitted-work.md @@ -0,0 +1,490 @@ +# Fix Codex Uncommitted Work — Implementation Plan + +> **Status:** Superseded by the 2026-07-21 stabilization pass documented in `AGENTS.md`. These procedural checkboxes are retained as history and are not a current backlog. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the broken bodymap layout, commit all Codex-authored uncommitted work (widget system, calibration backfill, onboarding enhancements, progress photo compare), and document the 19 Codex commits in AGENTS.md. + +**Architecture:** Six sequential tasks in dependency order. Task 1 (bodymap) is the only true code fix — it removes the `Modifier.offset()` hacks that cause clipping on device and replaces them with clean layout containment. Tasks 2–5 are build-verify-and-commit passes for each body of uncommitted Codex work. Task 6 documents everything in AGENTS.md. + +**Tech Stack:** Kotlin, Jetpack Compose, AndroidX Glance (widgets), ObjectBox, Gradle (`gradlew.bat assembleRelease --no-daemon`), ADB (`adb install -r`) + +--- + +## Files + +| File | Action | +|---|---| +| `app/src/main/java/com/ironlog/app/ui/screens/recovery/RecoveryHeatmapCard.kt` | Modify — remove offset/widthIn hacks | +| `app/src/main/java/com/ironlog/app/ui/screens/recovery/RecoveryMapScreen.kt` | Modify — remove offset/widthIn hacks, keep pager | +| `app/src/main/java/com/ironlog/app/widget/WidgetVisualStateResolver.kt` | New — commit as-is | +| `app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetAssets.kt` | New — commit as-is | +| `app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetContent.kt` | New — commit as-is | +| `app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetPresentation.kt` | New — commit as-is | +| `app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetSampleStates.kt` | New — commit as-is | +| `app/src/main/java/com/ironlog/app/widget/BadgeWidget.kt` | Modified — commit as-is | +| `app/src/main/java/com/ironlog/app/widget/DashboardWidget.kt` | Modified — commit as-is | +| `app/src/main/java/com/ironlog/app/widget/WarRoomWidget.kt` | Modified — commit as-is | +| `app/src/main/java/com/ironlog/app/widget/WidgetDataRepository.kt` | Modified — commit as-is | +| `app/src/main/java/com/ironlog/app/widget/WidgetState.kt` | Modified — commit as-is | +| `app/src/main/res/xml/badge_widget_info.xml` | Modified — commit as-is | +| `app/src/main/res/xml/dashboard_widget_info.xml` | Modified — commit as-is | +| `app/src/main/res/xml/warroom_widget_info.xml` | Modified — commit as-is | +| `app/src/main/res/drawable-nodpi/forgefox_widget_*.png` (10 files) | New — commit as-is | +| `app/src/main/res/drawable-nodpi/ic_forge_streak_dumbbell.png` | New — commit as-is | +| `app/src/test/java/com/ironlog/app/widget/ForgeFoxWidgetPresentationTest.kt` | New — commit as-is | +| `app/src/test/java/com/ironlog/app/widget/WidgetVisualStateResolverTest.kt` | New — commit as-is | +| `app/src/main/java/com/ironlog/app/data/objectbox/AthleteCalibrationEntity.kt` | Modified — commit after schema check | +| `app/objectbox-models/default.json` | Modified — commit with entity | +| `app/objectbox-models/default.json.bak` | Modified — commit with entity | +| `app/src/main/java/com/ironlog/app/data/repository/ImportExportRepository.kt` | Modified — commit with entity | +| `app/src/test/java/com/ironlog/app/data/repository/ImportExportRepositoryCalibrationBackfillTest.kt` | New — commit with entity | +| `app/src/main/java/com/ironlog/app/ui/screens/OnboardingScreen.kt` | Modified — commit with onboarding group | +| `app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingViewModel.kt` | Modified — commit with onboarding group | +| `app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingLedgerPreview.kt` | New — commit with onboarding group | +| `app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step3Baseline.kt` | Modified — commit with onboarding group | +| `app/src/main/java/com/ironlog/app/domain/gamification/IronLedgerEngine.kt` | Modified — commit with onboarding group | +| `app/src/main/java/com/ironlog/app/ui/viewmodel/GamificationViewModel.kt` | Modified — commit with onboarding group | +| `app/src/main/AndroidManifest.xml` | Modified — check + commit with onboarding group | +| `app/src/main/java/com/ironlog/app/navigation/AppNavigator.kt` | Modified — passes `historicalTrainingDaysPerWeek`; commit with onboarding group | +| `app/src/main/java/com/ironlog/app/ui/screens/body/ProgressPhotosScreen.kt` | Modified — commit with photo group | +| `app/src/main/java/com/ironlog/app/ui/screens/body/ProgressPhotoCompareLogic.kt` | New — commit with photo group | +| `app/src/test/java/com/ironlog/app/ui/screens/ProgressPhotoCompareLogicTest.kt` | New — commit with photo group | +| `Z:\KOTLIN\AGENTS.md` | Update — document 19 Codex commits | + +--- + +## Task 1: Fix bodymap `Modifier.offset()` clipping + +**Root cause:** `RecoveryHeatmapCard` and `RecoveryMapScreen` both apply `Modifier.offset(x=-24.dp, y=-38.dp)` to `BodyHalfCanvas`. `offset()` moves the visual rendering AFTER Compose has measured and placed the composable — the canvas stays in its original measured slot but draws outside it, clipping against parent containers and overlapping adjacent composables. The SVG math in `BodyMapCanvas.kt` is correct and should not be changed. + +**Files:** +- Modify: `app/src/main/java/com/ironlog/app/ui/screens/recovery/RecoveryHeatmapCard.kt` +- Modify: `app/src/main/java/com/ironlog/app/ui/screens/recovery/RecoveryMapScreen.kt` + +- [ ] **Step 1: Fix RecoveryHeatmapCard — remove offset/widthIn, delete dead constants** + + Open `RecoveryHeatmapCard.kt`. Delete the three constant declarations near the top of the file: + ```kotlin + // DELETE these three lines: + private val HOME_RECOVERY_BODY_MAP_MAX_WIDTH = 170.dp + private val BODY_MAP_LABEL_LIFT = (-38).dp + private val BODY_MAP_VISUAL_LEFT_NUDGE = (-24).dp + ``` + Also remove the `import androidx.compose.foundation.layout.offset` and `import androidx.compose.foundation.layout.widthIn` lines (they'll be unused after this change). + + Then locate the `mapModifier` val inside the `RecoveryHeatmapCard` composable (roughly 10 lines after the `sharedAspect` assignment) and replace it: + ```kotlin + // BEFORE: + val mapModifier = Modifier + .widthIn(max = HOME_RECOVERY_BODY_MAP_MAX_WIDTH) + .fillMaxWidth() + .padding(top = 2.dp) + .aspectRatio(sharedAspect) + .offset(x = BODY_MAP_VISUAL_LEFT_NUDGE, y = BODY_MAP_LABEL_LIFT) + + // AFTER: + val mapModifier = Modifier + .fillMaxWidth() + .aspectRatio(sharedAspect) + ``` + +- [ ] **Step 2: Fix RecoveryMapScreen — remove offset/widthIn, delete dead constants** + + Open `RecoveryMapScreen.kt`. Delete the three constant declarations near the top of `RecoveryBodyMap`: + ```kotlin + // DELETE these three lines: + private val RECOVERY_BODY_MAP_MAX_WIDTH = 132.dp + private val RECOVERY_BODY_MAP_SINGLE_WIDTH = 286.dp + private val BODY_MAP_VISUAL_LEFT_NUDGE = (-24).dp + ``` + Remove `import androidx.compose.foundation.layout.offset` and `import androidx.compose.foundation.layout.widthIn` if unused after the change. + + Locate `mapModifier` inside `RecoveryBodyMap` and replace: + ```kotlin + // BEFORE: + val mapModifier = Modifier + .widthIn(max = RECOVERY_BODY_MAP_SINGLE_WIDTH) + .fillMaxWidth() + .aspectRatio(sharedAspect) + .offset(x = BODY_MAP_VISUAL_LEFT_NUDGE) + + // AFTER: + val mapModifier = Modifier + .fillMaxWidth() + .padding(horizontal = 6.dp) + .aspectRatio(sharedAspect) + ``` + +- [ ] **Step 3: Build to verify no compile errors** + + Run: + ``` + .\gradlew.bat :app:compileDebugKotlin --no-daemon + ``` + Expected: `BUILD SUCCESSFUL` with zero errors or warnings about the deleted identifiers. + +- [ ] **Step 4: Build release APK** + + Run: + ``` + .\gradlew.bat assembleRelease --no-daemon + ``` + Expected: `BUILD SUCCESSFUL`. APK at `app/build/outputs/apk/release/app-release.apk`. + +- [ ] **Step 5: Install on device and verify visually** + + Run: + ``` + adb install -r app\build\outputs\apk\release\app-release.apk + ``` + Expected: `Success`. + + Open the app. Navigate to: Home screen → scroll to Muscle Recovery card. Verify: + - FRONT and BACK body outlines are fully visible within the card bounds + - No body SVG clips into the header above the card + - Labels "FRONT" / "BACK" appear above their respective maps without overlap + + Navigate to: Home → Recovery → (full Recovery screen). Verify: + - FRONT/BACK pill buttons appear at the top + - Tapping a pill shows the full body map BELOW the pills with no clipping + - Body map does not overlap the pill row + +- [ ] **Step 6: Commit** + + ```bash + git add app/src/main/java/com/ironlog/app/ui/screens/recovery/RecoveryHeatmapCard.kt + git add app/src/main/java/com/ironlog/app/ui/screens/recovery/RecoveryMapScreen.kt + git commit -m "fix(bodymap): remove Modifier.offset hacks that caused clipping on device + + Modifier.offset() moves visual rendering after layout measurement, so + the canvas drew outside its slot and clipped against parent containers. + Fix: replace offset/widthIn modifier chain with fillMaxWidth + aspectRatio + in both RecoveryHeatmapCard and RecoveryMapScreen. The SVG transform math + in BodyMapCanvas.kt (fitWidth/fitHeight canonical box, TOP anchor) is + correct and unchanged. + + Co-Authored-By: Claude Haiku 4.5 " + ``` + +--- + +## Task 2: Commit ForgeFox widget system + +**Context:** Five new Kotlin files implement a responsive Glance widget system with a visual-state machine (`WidgetVisualState` enum + `WidgetVisualStateResolver`) and per-state mascot art (`ForgeFoxWidgetAssets`). Three existing widgets (`BadgeWidget`, `DashboardWidget`, `WarRoomWidget`) are updated to use `SizeMode.Responsive` and delegate to the new `ForgeFoxWidgetContent` composable. `WidgetState` gains 6 new fields. 11 PNG assets + 3 XML updates are included. + +**Files:** +- New (commit as-is): `ForgeFoxWidgetAssets.kt`, `ForgeFoxWidgetContent.kt`, `ForgeFoxWidgetPresentation.kt`, `ForgeFoxWidgetSampleStates.kt`, `WidgetVisualStateResolver.kt` +- Modified (commit as-is): `BadgeWidget.kt`, `DashboardWidget.kt`, `WarRoomWidget.kt`, `WidgetDataRepository.kt`, `WidgetState.kt` +- New XML: `badge_widget_info.xml`, `dashboard_widget_info.xml`, `warroom_widget_info.xml` +- New PNG assets (11 files under `drawable-nodpi/`) +- New tests: `ForgeFoxWidgetPresentationTest.kt`, `WidgetVisualStateResolverTest.kt` + +- [ ] **Step 1: Run widget unit tests** + + Run: + ``` + .\gradlew.bat :app:testDebugUnitTest --tests "com.ironlog.app.widget.*" --no-daemon + ``` + Expected: `BUILD SUCCESSFUL`, all widget tests pass (0 failures). + +- [ ] **Step 2: Full release build to confirm no compile regressions** + + Run: + ``` + .\gradlew.bat assembleRelease --no-daemon + ``` + Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Commit** + + ```bash + git add app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetAssets.kt + git add app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetContent.kt + git add app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetPresentation.kt + git add app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetSampleStates.kt + git add app/src/main/java/com/ironlog/app/widget/WidgetVisualStateResolver.kt + git add app/src/main/java/com/ironlog/app/widget/BadgeWidget.kt + git add app/src/main/java/com/ironlog/app/widget/DashboardWidget.kt + git add app/src/main/java/com/ironlog/app/widget/WarRoomWidget.kt + git add app/src/main/java/com/ironlog/app/widget/WidgetDataRepository.kt + git add app/src/main/java/com/ironlog/app/widget/WidgetState.kt + git add app/src/main/res/xml/badge_widget_info.xml + git add app/src/main/res/xml/dashboard_widget_info.xml + git add app/src/main/res/xml/warroom_widget_info.xml + git add "app/src/main/res/drawable-nodpi/forgefox_widget_active.png" + git add "app/src/main/res/drawable-nodpi/forgefox_widget_at_risk.png" + git add "app/src/main/res/drawable-nodpi/forgefox_widget_coach.png" + git add "app/src/main/res/drawable-nodpi/forgefox_widget_comeback.png" + git add "app/src/main/res/drawable-nodpi/forgefox_widget_done.png" + git add "app/src/main/res/drawable-nodpi/forgefox_widget_early_workout.png" + git add "app/src/main/res/drawable-nodpi/forgefox_widget_mission_complete.png" + git add "app/src/main/res/drawable-nodpi/forgefox_widget_pb.png" + git add "app/src/main/res/drawable-nodpi/forgefox_widget_recovery.png" + git add "app/src/main/res/drawable-nodpi/forgefox_widget_workout_soon.png" + git add "app/src/main/res/drawable-nodpi/ic_forge_streak_dumbbell.png" + git add app/src/test/java/com/ironlog/app/widget/ForgeFoxWidgetPresentationTest.kt + git add app/src/test/java/com/ironlog/app/widget/WidgetVisualStateResolverTest.kt + git commit -m "feat: ForgeFox widget system — responsive layouts, visual state machine, mascot art + + Adds WidgetVisualState enum (10 states) + WidgetVisualStateResolver that maps + live WidgetState fields to the correct visual state. ForgeFoxWidgetAssets maps + states to drawable resources. ForgeFoxWidgetContent provides the unified Glance + composable. BadgeWidget, DashboardWidget, and WarRoomWidget switch to + SizeMode.Responsive (SMALL/MEDIUM/TALL/WIDE breakpoints). WidgetState gains + weeklyCompletion, todayCompleted, minutesUntilWorkout, isAtRisk, isRecoveryDay, + hasNewPb, and visualState fields. 11 PNG mascot assets included. + + Co-Authored-By: Claude Haiku 4.5 " + ``` + +--- + +## Task 3: Commit calibration backfill + import/export + +**Context:** `AthleteCalibrationEntity` gains a new `historicalTrainingDaysPerWeek: Int = 3` field (default 3). ObjectBox schema in `default.json` / `default.json.bak` is updated to include this property. `ImportExportRepository` gains export serialization for the new field, import deserialization with `optInt("historical_training_days_per_week", 3).coerceIn(1,7)`, and a new `backfillMissingAthleteStateRows()` function that synthesizes calibration + gamification rows from legacy exports that predate the calibration entity. A new test covers the backfill logic. + +**Files:** +- Modify: `AthleteCalibrationEntity.kt`, `ImportExportRepository.kt`, `default.json`, `default.json.bak` +- New: `ImportExportRepositoryCalibrationBackfillTest.kt` + +- [ ] **Step 1: Verify ObjectBox schema is consistent** + + Run: + ``` + .\gradlew.bat :app:compileDebugKotlin --no-daemon + ``` + ObjectBox annotation processing runs at compile time. If the schema is inconsistent (e.g., property ID collision), it will fail here with an error containing "entity" or "property" in the message. Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 2: Run the backfill test** + + Run: + ``` + .\gradlew.bat :app:testDebugUnitTest --tests "com.ironlog.app.data.repository.ImportExportRepositoryCalibrationBackfillTest" --no-daemon + ``` + Expected: `BUILD SUCCESSFUL`, 0 failures. + +- [ ] **Step 3: Full release build** + + Run: + ``` + .\gradlew.bat assembleRelease --no-daemon + ``` + Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Commit** + + ```bash + git add app/src/main/java/com/ironlog/app/data/objectbox/AthleteCalibrationEntity.kt + git add app/objectbox-models/default.json + git add app/objectbox-models/default.json.bak + git add app/src/main/java/com/ironlog/app/data/repository/ImportExportRepository.kt + git add app/src/test/java/com/ironlog/app/data/repository/ImportExportRepositoryCalibrationBackfillTest.kt + git commit -m "feat: add historicalTrainingDaysPerWeek to calibration + backfill legacy exports + + AthleteCalibrationEntity gains historicalTrainingDaysPerWeek (default 3). + ImportExportRepository serializes/deserializes the new field and adds + backfillMissingAthleteStateRows() to synthesize calibration + gamification + rows from legacy exports that predate the calibration table. + + Co-Authored-By: Claude Haiku 4.5 " + ``` + +--- + +## Task 4: Commit onboarding + gamification enhancements + +**Context:** `OnboardingDraft` and `OnboardingViewModel` gain `historicalTrainingDaysPerWeek`. `Step3Baseline` receives the new field + two display props (`seededGrade`, `seededStats`) from `OnboardingScreen`. `OnboardingScreen` builds a `seededSnapshot` via `buildOnboardingLedgerSnapshot(draft)` (from new `OnboardingLedgerPreview.kt`) and passes it to Step 3 and the final badge display. `IronLedgerEngine` and `GamificationViewModel` have related changes. Several onboarding persistence and view model tests are updated. `AppNavigator.kt` passes the new field through to settings commit. + +**Files:** +- Modify: `OnboardingScreen.kt`, `OnboardingViewModel.kt`, `Step3Baseline.kt`, `IronLedgerEngine.kt`, `GamificationViewModel.kt`, `AppNavigator.kt`, `AndroidManifest.xml` +- New: `OnboardingLedgerPreview.kt` +- Modify (tests): `OnboardingPersistenceTest.kt`, `OnboardingViewModelTest.kt`, `ParityClosureLogicTest.kt`, `IronLedgerBaselineSeedingTest.kt` + +- [ ] **Step 1: Run onboarding + gamification tests** + + Run: + ``` + .\gradlew.bat :app:testDebugUnitTest --tests "com.ironlog.app.navigation.OnboardingPersistenceTest" --tests "com.ironlog.app.ui.screens.onboarding.OnboardingViewModelTest" --tests "com.ironlog.app.ui.screens.ParityClosureLogicTest" --tests "com.ironlog.app.domain.gamification.IronLedgerBaselineSeedingTest" --no-daemon + ``` + Expected: `BUILD SUCCESSFUL`, 0 failures. + +- [ ] **Step 2: Full release build** + + Run: + ``` + .\gradlew.bat assembleRelease --no-daemon + ``` + Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Check AndroidManifest.xml diff for anything unexpected** + + Run: + ``` + git diff HEAD -- app/src/main/AndroidManifest.xml + ``` + Verify the diff contains only expected changes (e.g., new activity/receiver declarations for widgets or onboarding). No `` additions should be present unless they are intentional. If something looks wrong, do NOT stage it and investigate before proceeding. + +- [ ] **Step 4: Commit** + + ```bash + git add app/src/main/java/com/ironlog/app/ui/screens/OnboardingScreen.kt + git add app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingViewModel.kt + git add app/src/main/java/com/ironlog/app/ui/screens/onboarding/OnboardingLedgerPreview.kt + git add app/src/main/java/com/ironlog/app/ui/screens/onboarding/steps/Step3Baseline.kt + git add app/src/main/java/com/ironlog/app/domain/gamification/IronLedgerEngine.kt + git add app/src/main/java/com/ironlog/app/ui/viewmodel/GamificationViewModel.kt + git add app/src/main/java/com/ironlog/app/navigation/AppNavigator.kt + git add app/src/main/AndroidManifest.xml + git add app/src/test/java/com/ironlog/app/navigation/OnboardingPersistenceTest.kt + git add app/src/test/java/com/ironlog/app/domain/gamification/IronLedgerBaselineSeedingTest.kt + git add app/src/test/java/com/ironlog/app/ui/screens/onboarding/OnboardingViewModelTest.kt + git add app/src/test/java/com/ironlog/app/ui/screens/ParityClosureLogicTest.kt + git commit -m "feat: onboarding tracks historicalTrainingDaysPerWeek, live IronLedger grade preview + + OnboardingDraft/VM gain historicalTrainingDaysPerWeek. Step3Baseline shows + a live grade preview seeded from current draft answers via + buildOnboardingLedgerSnapshot(). Final badge on Step8 uses the same snapshot + instead of calculateQualifiedBadge(). AppNavigator persists the new field. + IronLedgerEngine and GamificationViewModel updated for baseline seeding. + + Co-Authored-By: Claude Haiku 4.5 " + ``` + +--- + +## Task 5: Commit progress photo compare logic + +**Context:** New `ProgressPhotoCompareLogic.kt` encapsulates date-bucketing and comparison state for before/after photo pairs. `ProgressPhotosScreen.kt` is updated to use it. A new test `ProgressPhotoCompareLogicTest.kt` covers the logic. + +**Files:** +- New: `ProgressPhotoCompareLogic.kt`, `ProgressPhotoCompareLogicTest.kt` +- Modify: `ProgressPhotosScreen.kt` + +- [ ] **Step 1: Run the compare logic test** + + Run: + ``` + .\gradlew.bat :app:testDebugUnitTest --tests "com.ironlog.app.ui.screens.ProgressPhotoCompareLogicTest" --no-daemon + ``` + Expected: `BUILD SUCCESSFUL`, 0 failures. + +- [ ] **Step 2: Full release build** + + Run: + ``` + .\gradlew.bat assembleRelease --no-daemon + ``` + Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Commit** + + ```bash + git add app/src/main/java/com/ironlog/app/ui/screens/body/ProgressPhotoCompareLogic.kt + git add app/src/main/java/com/ironlog/app/ui/screens/body/ProgressPhotosScreen.kt + git add app/src/test/java/com/ironlog/app/ui/screens/ProgressPhotoCompareLogicTest.kt + git commit -m "feat: progress photo before/after compare logic + + Extracts ProgressPhotoCompareLogic for date-bucketed before/after photo pair + selection. ProgressPhotosScreen updated to use the new logic class. + + Co-Authored-By: Claude Haiku 4.5 " + ``` + +--- + +## Task 6: Update AGENTS.md with the 19 Codex commits + +**Context:** `Z:\KOTLIN\AGENTS.md` already has a 2026-06-07 Codex entry documenting the failed bodymap work and uncommitted changes — but it does NOT document the 19 actual commits Codex made (from `8088972` to `7c66fb9`). Add a new entry for those commits. + +**File:** `Z:\KOTLIN\AGENTS.md` + +- [ ] **Step 1: Insert the 19-commit summary entry into AGENTS.md** + + Open `Z:\KOTLIN\AGENTS.md`. Locate the existing 2026-06-07 Codex entry. Add the following NEW entry ABOVE it (directly below the `---` separator at the top of the file's entry list): + + ```markdown + ### 2026-06-07 — Codex: features and polish (19 commits, `8088972`–`7c66fb9`) + + **ExerciseProgressScreen — Vico charts (commits `0546a45`, `3450046`)** + - Replaced ~75-line manual `Canvas` drawing in `LineChartCard` / `BarChartCard` with Vico 2.1.2 `CartesianChartHost` + `CartesianChartModelProducer`. + - Added `com.patrykandpatrick.vico:compose-m3:2.1.2` to `app/build.gradle.kts`. + - Fixed Compose composition-rules violation: hoisted producer + `LaunchedEffect` above empty-state early-return. + - Added `CartesianValueFormatter` to x-axis so dates render instead of indices. + - File: `app/src/main/java/com/ironlog/app/ui/screens/stats/ExerciseProgressScreen.kt` + + **ProgressPhotosScreen — Coil 3 (commits `ed9d69b`, `cb2e0e5`, `8a9bcea`)** + - Replaced `BitmapFactory` manual bitmap loading with `AsyncImage` from Coil 3. + - Added `coil-android` artifact to `app/build.gradle.kts` for `content://` URI support. + - Added `isNotBlank()` guard on all 4 `AsyncImage` model expressions. + - File: `app/src/main/java/com/ironlog/app/ui/screens/body/ProgressPhotosScreen.kt` + + **PlanQrScanScreen — Accompanist + permission fix (commits `bd0d197`, `3e6d853`)** + - Replaced `ActivityResultContracts` boilerplate with Accompanist permission API. + - Fixed rationale branches: `shouldShowRationale` triggers re-request; permanent denial opens Settings. + - Fixed scanner executor leak via `DisposableEffect` shutdown. + - File: `app/src/main/java/com/ironlog/app/ui/screens/plans/PlanQrScanScreen.kt` + + **CloudAiEngine — Ktor 3.1.3 (commits `e43dad5`, `13b747f`, `d920ab0`)** + - Replaced raw `OkHttp + JSONObject` with `Ktor 3.1.3` HTTP client. + - Added `expectSuccess = true`; clarified `jsonMode` KDoc. + - File: `app/src/main/java/com/ironlog/app/domain/intelligence/CloudAiEngine.kt` + + **Timber logging (commits `8283182`, `6f49573`)** + - Added `Timber 5.0.1` dependency; planted `DebugTree` in `IronLogApplication`. + - Instrumented 5 critical `runCatching` sites with `Timber.e(e, ...)`. + - Quality-reviewed and improved instrumentation coverage. + + **kotlinx-datetime migration (commits `dccd7a0`, `ad8f074`)** + - Migrated `formatHistoryDate` and `startOfWeekMillis` to `kotlinx-datetime 0.6.2`. + - Completed migration in `BodyWeightScreen.kt` and `Dates.kt`. + + **Polish, onboarding, misc (commits `13b492d`, `3cfe3c8`, `755f1c7`, `80bd42c`, `7c66fb9`)** + - Added clarifying comments per final code review. + - Added missing stub fields to unblock release build. + - Restored IronLog onboarding design. + - Replaced placeholder iron ledger badges with image-backed assets. + - Fixed onboarding completion reveal hold timing. + + **UI polish (commit `8088972`)** + - Active workout: "Watch on YouTube" in exercise 3-dot menu; `contentPadding` bottom fix; `RestTimerPanel` `navigationBarsPadding`; MINIMIZE touch target; swap-sheet empty state / `maxLines`. + - Stats: Regenerate row touch target; `QuickNavButton`/`MiniAction`/`CoachMiniAction` clip-before-background for ripple containment; PB empty state horizontal padding. + - CreateExercise: `navigationBarsPadding` bottom spacer; copy-picker `maxLines`. + - PlanQrScan: executor `DisposableEffect` shutdown; scan-error `maxLines`; instruction overlay scrim + `navigationBarsPadding`. + + **Build verification:** All 19 commits build successfully. Release APK installed on device `R5CY925TFHT` via `adb install -r`. + ``` + +- [ ] **Step 2: Also add an entry for the bodymap fix + uncommitted commits (from Task 1–5)** + + Directly below the entry just inserted (still above the existing Codex entry), add: + + ```markdown + ### 2026-06-07 — Fix bodymap clipping + commit Codex uncommitted work + + **Bodymap fix (`RecoveryHeatmapCard.kt`, `RecoveryMapScreen.kt`)** + - Removed `Modifier.offset(x=-24.dp, y=-38.dp)` and `Modifier.widthIn()` hacks from both files. These caused body maps to render outside their Compose layout slot, clipping against parent containers on device. + - Kept all `BodyMapCanvas.kt` changes from Codex (fitWidth/fitHeight canonical box, TOP anchor offsetY=0f) — those are correct. + - Pager (FRONT/BACK pills + HorizontalPager) in RecoveryMapScreen preserved. + + **ForgeFox widget system committed** — 5 new Kotlin files, 3 modified widgets, 11 PNG mascot assets, 3 XML updates. `WidgetVisualState` enum + `WidgetVisualStateResolver` + `ForgeFoxWidgetContent` composable. + + **Calibration backfill committed** — `AthleteCalibrationEntity` gains `historicalTrainingDaysPerWeek`. `ImportExportRepository` serializes it and adds `backfillMissingAthleteStateRows()` for legacy export compatibility. ObjectBox schema updated. + + **Onboarding/gamification committed** — `OnboardingDraft`/`VM` gain `historicalTrainingDaysPerWeek`. Step3Baseline shows live IronLedger grade preview via `buildOnboardingLedgerSnapshot()`. `IronLedgerEngine` + `GamificationViewModel` updated for baseline seeding. + + **Progress photo compare committed** — New `ProgressPhotoCompareLogic.kt` + test. `ProgressPhotosScreen` updated to use it. + ``` + +--- + +## Self-Review + +**Spec coverage:** All 5 bodies of uncommitted work are covered (bodymap, widgets, calibration, onboarding, photos). AGENTS.md update included. + +**Placeholder scan:** None found. All code snippets show actual before/after values from git diff. + +**Type consistency:** `BodyHalfCanvas` still receives `fitWidth`/`fitHeight` params as defined in `BodyMapCanvas.kt`; mapModifier chains are complete Modifier expressions with no references to deleted constants. + +**Scope:** Six independent tasks, each producting a clean commit. Largest single task (onboarding, Task 4) runs its own tests before committing. diff --git a/docs/superpowers/plans/2026-06-07-forge-fox-widget-polish.md b/docs/superpowers/plans/2026-06-07-forge-fox-widget-polish.md new file mode 100644 index 0000000..384c9a4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-forge-fox-widget-polish.md @@ -0,0 +1,225 @@ +# Forge Fox Widget Polish Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Polish the existing Forge Fox Android home-screen widgets so they look less like placeholders while preserving real app data wiring. + +**Architecture:** Keep the existing Jetpack Glance widget architecture and receiver classes. Extract pure widget presentation/layout decisions into small testable Kotlin helpers, then make `ForgeFoxWidgetContent.kt` use those helpers for size-specific composition. Replace the rough temporary vector icon with the cleaned reference-based official icon asset. + +**Tech Stack:** Kotlin, Jetpack Glance, Android resource drawables, JVM unit tests, Gradle. + +--- + +### Task 1: Lock State And Layout Behavior With Tests + +**Files:** +- Modify: `app/src/test/java/com/ironlog/app/widget/WidgetVisualStateResolverTest.kt` +- Create: `app/src/test/java/com/ironlog/app/widget/ForgeFoxWidgetPresentationTest.kt` +- Modify: `app/src/main/java/com/ironlog/app/widget/WidgetVisualStateResolver.kt` +- Create: `app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetPresentation.kt` + +- [x] **Step 1: Add a failing resolver test for safe active streak** + +Add this test to `WidgetVisualStateResolverTest.kt`: + +```kotlin +@Test +fun `safe incomplete streak remains active instead of coach mode`() { + val state = resolveWidgetVisualState( + WidgetVisualInputs( + streakDays = 23, + todayCompleted = false, + minutesUntilWorkout = null, + isAtRisk = false, + isRecoveryDay = false, + hasRecentNewPb = false, + returnedAfterGap = false, + weekSessionsCount = 2, + weeklyGoal = 4, + scheduledWorkoutHour = null, + ) + ) + + assertEquals(WidgetVisualState.ACTIVE_STREAK, state) +} +``` + +- [x] **Step 2: Run the focused test and verify RED** + +Run: + +```powershell +.\gradlew.bat :app:testDebugUnitTest --tests com.ironlog.app.widget.WidgetVisualStateResolverTest --no-daemon +``` + +Expected: FAIL because the current resolver returns `NO_EXCUSES`. + +- [x] **Step 3: Add failing presentation tests** + +Create `ForgeFoxWidgetPresentationTest.kt`: + +```kotlin +package com.ironlog.app.widget + +import com.ironlog.app.R +import org.junit.Assert.assertEquals +import org.junit.Test + +class ForgeFoxWidgetPresentationTest { + @Test + fun `layout resolver classifies canonical widget sizes`() { + assertEquals(ForgeWidgetLayoutClass.SMALL, resolveForgeWidgetLayoutClass(110f, 110f, ForgeWidgetLayoutClass.MEDIUM)) + assertEquals(ForgeWidgetLayoutClass.MEDIUM, resolveForgeWidgetLayoutClass(180f, 180f, ForgeWidgetLayoutClass.SMALL)) + assertEquals(ForgeWidgetLayoutClass.TALL, resolveForgeWidgetLayoutClass(220f, 320f, ForgeWidgetLayoutClass.MEDIUM)) + assertEquals(ForgeWidgetLayoutClass.WIDE, resolveForgeWidgetLayoutClass(320f, 160f, ForgeWidgetLayoutClass.MEDIUM)) + } + + @Test + fun `streak icon uses the single official drawable resource`() { + assertEquals(R.drawable.ic_forge_streak_dumbbell, ForgeFoxWidgetAssets.streakIcon) + } + + @Test + fun `presentation keeps short labels for constrained widgets`() { + val active = forgePresentationFor(WidgetVisualState.ACTIVE_STREAK, null) + val atRisk = forgePresentationFor(WidgetVisualState.AT_RISK, null) + + assertEquals("DAY STREAK", active.title) + assertEquals("AT RISK", atRisk.title) + assertEquals("Don't let it break!", atRisk.message) + } +} +``` + +- [x] **Step 4: Run the focused presentation test and verify RED** + +Run: + +```powershell +.\gradlew.bat :app:testDebugUnitTest --tests com.ironlog.app.widget.ForgeFoxWidgetPresentationTest --no-daemon +``` + +Expected: FAIL because `resolveForgeWidgetLayoutClass`, `ForgeFoxWidgetAssets.streakIcon`, and `forgePresentationFor` do not exist yet. + +### Task 2: Implement Testable Presentation Helpers + +**Files:** +- Modify: `app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetAssets.kt` +- Create: `app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetPresentation.kt` +- Modify: `app/src/main/java/com/ironlog/app/widget/WidgetVisualStateResolver.kt` + +- [x] **Step 1: Add `streakIcon` to `ForgeFoxWidgetAssets`** + +Add: + +```kotlin +@DrawableRes +val streakIcon: Int = R.drawable.ic_forge_streak_dumbbell +``` + +- [x] **Step 2: Create presentation helper** + +Create `ForgeFoxWidgetPresentation.kt` with `ForgeWidgetPresentation`, `resolveForgeWidgetLayoutClass`, and `forgePresentationFor`. Use the same state colors as the current widget, but centralize them outside the composable. + +- [x] **Step 3: Fix active-streak resolver priority** + +Change `resolveWidgetVisualState()` so the default safe incomplete state is `ACTIVE_STREAK`; reserve `NO_EXCUSES` for a late-day nudge when there is a streak, no completion, no workout-soon, and the scheduled workout hour is missing or already earlier than the current risk window. + +- [x] **Step 4: Verify GREEN for focused tests** + +Run: + +```powershell +.\gradlew.bat :app:testDebugUnitTest --tests com.ironlog.app.widget.WidgetVisualStateResolverTest --tests com.ironlog.app.widget.ForgeFoxWidgetPresentationTest --no-daemon +``` + +Expected: both test classes pass. + +### Task 3: Replace The Rough Icon With The Official Extracted Mark + +**Files:** +- Delete: `app/src/main/res/drawable/ic_forge_streak_dumbbell.xml` +- Create: `app/src/main/res/drawable-nodpi/ic_forge_streak_dumbbell.png` +- Source: `artifacts/fire-dumbbell-symbol/forge_streak_dumbbell_extracted_transparent.png` + +- [x] **Step 1: Remove the temporary vector** + +Delete the rough vector drawable. + +- [x] **Step 2: Add the cleaned extracted PNG under the same resource name** + +Copy the extracted transparent PNG to `drawable-nodpi/ic_forge_streak_dumbbell.png`, preserving the `R.drawable.ic_forge_streak_dumbbell` resource name. + +- [x] **Step 3: Confirm only one official icon resource exists** + +Run: + +```powershell +Get-ChildItem -Path .\app\src\main\res -Recurse -File | Where-Object { $_.Name -like 'ic_forge_streak_dumbbell*' } | Select-Object FullName +``` + +Expected: one file, the PNG in `drawable-nodpi`. + +### Task 4: Polish Glance Layout Composition + +**Files:** +- Modify: `app/src/main/java/com/ironlog/app/widget/ForgeFoxWidgetContent.kt` + +- [x] **Step 1: Use the extracted presentation helpers** + +Remove the private presentation data class and private layout resolver from `ForgeFoxWidgetContent.kt`. Use `forgePresentationFor(state.visualState, state.minutesUntilWorkout)` and `resolveForgeWidgetLayoutClass(...)`. + +- [x] **Step 2: Tighten small widgets** + +Small widgets use icon size `30.dp`, number size `34.sp`, title `12.sp`, no weekly row, and a mascot area that emphasizes the face. + +- [x] **Step 3: Tighten medium widgets** + +Medium widgets use a fixed top lockup, a larger mascot area, and a compact weekly row band at the bottom. The mascot should not compete with the weekly row. + +- [x] **Step 4: Tighten tall widgets** + +Tall widgets reserve top for lockup/title/message, middle for large mascot, and bottom for weekly row. Avoid floating raw dots. + +- [x] **Step 5: Tighten wide widgets** + +Wide widgets use a fixed left column and a right mascot area. The layout should not look like a stretched square. + +### Task 5: Generate Preview And Verify + +**Files:** +- Create: `artifacts/actual-widget-preview-2026-06-07/forgefox_actual_widget_preview_board_polished.png` + +- [x] **Step 1: Generate a local preview board** + +Render the actual current icon, colors, text, and mascot assets into a comparison board. + +- [x] **Step 2: Run tests** + +Run: + +```powershell +.\gradlew.bat :app:testDebugUnitTest --no-daemon +``` + +Expected: unit tests pass. + +- [x] **Step 3: Run debug build** + +Run: + +```powershell +.\gradlew.bat :app:assembleDebug --no-daemon +``` + +Expected: debug APK builds successfully. + +- [x] **Step 4: Check emulator availability** + +Run: + +```powershell +adb devices +``` + +Expected: if no device is attached, report that live launcher screenshot validation is unavailable. diff --git a/docs/superpowers/specs/2026-05-18-onboarding-design.md b/docs/superpowers/specs/2026-05-18-onboarding-design.md new file mode 100644 index 0000000..6521b65 --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-onboarding-design.md @@ -0,0 +1,399 @@ +# IronLog Onboarding — Design Spec +**Date:** 2026-05-18 +**Status:** Approved + +--- + +## Overview + +An 8-screen cinematic onboarding flow with Solo Leveling "SYSTEM AWAKENING" aesthetic. Every screen is dark (near-black background), electric-blue (`#4FC3F7`) or gold (`#FFD700`) accent glows, particle shimmer effects, and rank-badge motifs. The user feels like they are entering a game, not filling a form. + +**All text labels, copy, color tokens, and screen ordering are defined in a single `OnboardingConfig.kt` data file** — no hardcoded strings or values scattered across composables. Any screen can be reordered, retitled, or reskinned by editing that one file. + +--- + +## Architecture + +### Files to create / modify + +| File | Role | +|------|------| +| `ui/screens/OnboardingScreen.kt` | Root composable — `HorizontalPager`, progress dots, nav | +| `ui/screens/onboarding/OnboardingConfig.kt` | All static copy, color tokens, step definitions | +| `ui/screens/onboarding/OnboardingViewModel.kt` | Holds transient state; performs atomic save on completion | +| `ui/screens/onboarding/steps/Step1Awakening.kt` | Cinematic splash | +| `ui/screens/onboarding/steps/Step2Registration.kt` | Hunter name input | +| `ui/screens/onboarding/steps/Step3Classification.kt` | Rank / training level | +| `ui/screens/onboarding/steps/Step4Quota.kt` | Weekly days + unit toggle | +| `ui/screens/onboarding/steps/Step5GoalMode.kt` | Goal mode 2×2 grid | +| `ui/screens/onboarding/steps/Step6AiAbilities.kt` | AI tier selector + Cloud API key | +| `ui/screens/onboarding/steps/Step7Permissions.kt` | Camera / Health Connect / Notifications | +| `ui/screens/onboarding/steps/Step8Arise.kt` | Cinematic finale + ARISE CTA | +| `navigation/AppNavigator.kt` | Already routes to "Onboarding"; minor update to pass VM | +| `domain/badges/BadgeDefinitions.kt` | Badge catalog used by gamification (16 badges) | +| `res/values/strings_onboarding.xml` | String resources for all onboarding copy | + +### State management + +`OnboardingViewModel` holds: +```kotlin +data class OnboardingDraft( + val userName: String = "", + val progressionStyle: ProgressionStyle = ProgressionStyle.LINEAR, + val goalMode: GoalMode = GoalMode.STRENGTH, + val weeklyGoalDays: Int = 3, + val weightUnit: WeightUnit = WeightUnit.KG, + val cloudAiApiKey: String = "", + val cloudAiModelName: String = "gemini-2.0-flash", + val cloudAiProviderPreset: CloudAiProviderPreset = CloudAiProviderPreset.GEMINI, + val intelligenceMode: IntelligenceMode = IntelligenceMode.LOCAL, + val cameraGranted: Boolean = false, + val healthConnectGranted: Boolean = false, + val notificationsGranted: Boolean = false, +) +``` + +On Screen 8 "ARISE" tap: `SettingsRepository.saveOnboardingData(draft)` called atomically, then `onboardingComplete = true`, navigate to Tabs. + +### Navigation + +- Forward-only: swipe disabled on `HorizontalPager`, all progress via explicit CTA buttons +- Screen 1 auto-advances after 2.5 s via `LaunchedEffect` +- Progress dots shown on screens 2–8 (not on cinematic screens 1 and 8) +- Back navigation: system back moves to previous page (handled by `HorizontalPager` `userScrollEnabled = false`, manual back via `PagerState.scrollToPage`) + +--- + +## Screen-by-Screen Specification + +### Screen 1 — SYSTEM AWAKENING + +**Purpose:** Cinematic tone-setter. No user input. + +**Layout:** +- Full-screen black (`Color(0xFF050508)`) +- Particle field: 40–60 small white/blue dots drifting upward via `rememberInfiniteTransition` +- IronLog logo: fades in from alpha 0 → 1 over 1.2 s, accompanied by shimmer sweep (horizontal gradient mask animated left-to-right) +- Typewriter text below logo: + - Line 1: `"THE SYSTEM HAS DETECTED A NEW HUNTER"` (letter-by-letter, 40ms/char) + - Line 2: `"INITIALIZING REGISTRATION PROTOCOL..."` (starts after Line 1 finishes) +- Auto-advance after 2.5 s total + +**Colors:** Background `#050508`, text `#4FC3F7` (electric blue), logo glow `#4FC3F7` at 60% alpha + +--- + +### Screen 2 — HUNTER REGISTRATION + +**Purpose:** Collect `userName`. + +**Layout:** +- Header: `"HUNTER DESIGNATION"` (all-caps, letter-spacing 4sp, `#4FC3F7`) +- Subtext: `"Enter your designation, Hunter"` (muted gray) +- Single `OutlinedTextField` centered vertically: + - Focused border: animated glow `#4FC3F7` + - Unfocused border: `#4FC3F7` at 30% alpha + - Cursor: electric blue + - `keyboardOptions = KeyboardOptions(capitalization = Words, imeAction = Done)` +- CTA button `"CONFIRM IDENTITY →"`: disabled (50% alpha) until `userName.isNotBlank()` +- Keyboard opens automatically via `LaunchedEffect(Unit) { focusRequester.requestFocus() }` + +**Validation:** 1–30 characters. Trims whitespace before save. + +--- + +### Screen 3 — TRAINING CLASSIFICATION + +**Purpose:** Set `progressionStyle` and seed `goalMode`. + +**Layout:** +- Header: `"ASSESS YOUR CURRENT RANK"` +- Subtext: `"The System will calibrate your training path"` +- Three cards displayed horizontally (full-width, scrollable if screen narrow): + +| Card | Rank Badge | Label | Description | progressionStyle | +|------|-----------|-------|-------------|-----------------| +| Left | E (gray) | `NOVICE` | `"Starting my journey"` | `LINEAR` | +| Center | C (silver) | `INTERMEDIATE` | `"Training 6+ months"` | `DOUBLE_PROGRESSION` | +| Right | A (gold) | `ADVANCED` | `"2+ years, serious lifter"` | `UNDULATING` | + +- Selected card: `#4FC3F7` border (2dp glow), rank badge brightens to full opacity +- Unselected: border `#4FC3F7` at 20% alpha, badge at 60% alpha +- Auto-advance on card tap (no separate CTA button needed) +- Seeds `goalMode` default: NOVICE → STRENGTH, INTERMEDIATE → HYPERTROPHY, ADVANCED → PERFORMANCE + +**Rank badge component:** Reusable `RankBadge(rank: String, color: Color, size: Dp)` composable showing letter in metallic ring. + +--- + +### Screen 4 — WEEKLY MISSION QUOTA + +**Purpose:** Set `weeklyGoalDays` and `weightUnit`. + +**Layout:** +- Header: `"SET YOUR WEEKLY OBJECTIVE"` +- Day picker: 7 dots in a row, labeled `M T W T F S S` + - Dot size: 40dp + - Selected: filled `#4FC3F7`, white letter, drop shadow glow + - Unselected: outlined `#4FC3F7` at 30% alpha, muted letter + - Tap toggles. Rule: minimum 1 day must always be selected (if user deselects last active dot, snap back) + - Days selected need not be consecutive — user picks any combination + - `weeklyGoalDays = selectedDots.size` +- Live counter below dots: `"{N} SESSIONS PER WEEK"` (large, gold) +- **Unit toggle** (secondary, below counter): + - Pill toggle: `[ KG ] [ LBS ]` + - Selected side: `#4FC3F7` background, white text + - Unselected: transparent background, muted text +- CTA: `"SET OBJECTIVE →"` + +--- + +### Screen 5 — GOAL MODE + +**Purpose:** Confirm / override `goalMode`. + +**Layout:** +- Header: `"CHOOSE YOUR COMBAT STYLE"` +- 2×2 grid of large tappable cards (each ~160×180dp): + +| Position | Icon | Label | goalMode | +|----------|------|-------|----------| +| Top-left | Flexed arm (💪) | `STRENGTH` | `STRENGTH` | +| Top-right | Trophy (🏆) | `HYPERTROPHY` | `HYPERTROPHY` | +| Bottom-left | Lightning (⚡) | `PERFORMANCE` | `PERFORMANCE` | +| Bottom-right | Runner (🏃) | `ENDURANCE` | `ENDURANCE` | + +- Icons: use vector drawables from `res/drawable/` (no emoji in production code) +- Selected card: gold border glow, background tint `#FFD700` at 8% alpha +- Subtext per card (1 line): Strength → `"Max weight, low reps"` / Hypertrophy → `"Size & muscle growth"` / Performance → `"Speed & power"` / Endurance → `"High volume, stamina"` +- Pre-selected based on Screen 3 seed +- CTA: `"LOCK IN →"` + +--- + +### Screen 6 — UNLOCK AI ABILITIES + +**Purpose:** Set `intelligenceMode`, optionally add cloud API key. + +**Layout:** +- Header: `"ENHANCE YOUR HUNTER ABILITIES"` +- Subtext: `"Unlock AI-powered coaching and adaptive recommendations"` + +**Tier A — On-Device (always active, shown as already-unlocked):** +- Small card, gray border, lock-open icon +- `"ON-DEVICE INTELLIGENCE — ACTIVE"` +- Subtext: `"Recovery scoring, workout suggestion, effort tracking"` +- No interaction needed + +**Tier B — Cloud AI (optional expand):** +- Large card with lock icon and `"CLOUD ABILITIES"` header +- Default collapsed state shows: lock icon + `"TAP TO UNLOCK CLOUD AI COACHING"` +- Tap expands card with `AnimatedContent`: + - Provider row: `"API PROVIDER"` label + dropdown chip `[GOOGLE GEMINI ▼]` + - Options: `Gemini`, `OpenAI`, `Custom` + - API key field: `OutlinedTextField` with `visualTransformation = PasswordVisualTransformation()` + eye toggle + - Placeholder: `"Paste your API key here"` + - Helper text: `"🔒 Stored locally in encrypted SharedPreferences, never uploaded"` + - Recommended badge for Gemini (shown when Gemini selected): + ``` + ✦ GEMINI 2.0 FLASH — RECOMMENDED (FREE) + 1,500 requests / day · 15 req/min · 1M token context + → Get free key at aistudio.google.com + ``` + - Model selector (dropdown chip): + - Gemini: `2.0 Flash (free, 1500/day)`, `2.5 Flash (250/day)`, `2.5 Pro (100/day)` + - OpenAI: `gpt-4o-mini`, `gpt-4o` + - Custom: freeform model name field + - Validation: Gemini keys start with `AIza` (show inline warning if format wrong) + - When valid key entered → card border turns `#4FC3F7`, shows `"CLOUD AI READY ✓"` + +- `intelligenceMode`: + - No key entered → `LOCAL` + - Valid key entered → `AUTO` (device + cloud blend) + +- CTA at bottom: `"ACTIVATE →"` (or `"SKIP FOR NOW"` secondary link) + +**Key storage:** `EncryptedSharedPreferences` (already in `build.gradle.kts` via `security-crypto`). Field: `"cloud_ai_api_key"`. + +--- + +### Screen 7 — GRANT PERMISSIONS + +**Purpose:** Request CAMERA, Health Connect, POST_NOTIFICATIONS. + +**Layout:** +- Header: `"UNLOCK HUNTER ABILITIES"` +- Subtext: `"Each permission activates a System feature. All optional."` +- Three permission cards stacked vertically: + +| Icon | Title | Description | Permission | +|------|-------|-------------|------------| +| Camera icon | `SCANNER — QR SHARING` | `"Scan & share workout plans as QR codes"` | `CAMERA` | +| Heart icon | `VITALS — HEALTH CONNECT` | `"Auto-import sleep, HRV, heart rate for recovery scores"` | Health Connect bundle | +| Bell icon | `ALERTS — NOTIFICATIONS` | `"Rest timer, streak reminders, daily check-ins"` | `POST_NOTIFICATIONS` | + +**Per card UI:** +- Right side: `[GRANT]` button chip (outlined, `#4FC3F7`) +- On grant → chip changes to `[GRANTED ✓]` (filled green-teal, no longer tappable) +- On denial → chip shows `[DENIED]` (muted red outline) with note `"Grant later in Settings"` +- Cards use `rememberLauncherForActivityResult`: + - Camera: `ActivityResultContracts.RequestPermission(Manifest.permission.CAMERA)` + - Notifications: `ActivityResultContracts.RequestPermission(Manifest.permission.POST_NOTIFICATIONS)` (Android 13+ guard: `if (Build.VERSION.SDK_INT >= 33)`) + - Health Connect: `PermissionController.createRequestPermissionResultContract()` with set of HC permissions (sleep, resting HR, HRV, exercise session write) + +**Health Connect permissions set:** +```kotlin +setOf( + HealthPermission.getReadPermission(SleepSessionRecord::class), + HealthPermission.getReadPermission(RestingHeartRateRecord::class), + HealthPermission.getReadPermission(HeartRateVariabilityRmssdRecord::class), + HealthPermission.getWritePermission(ExerciseSessionRecord::class), +) +``` + +- CTA: `"CONTINUE →"` (always active — all permissions optional) + +--- + +### Screen 8 — ARISE + +**Purpose:** Cinematic finale, final save, navigate to app. + +**Layout:** +- Full-screen black +- Particle burst animation from screen center (200 particles, outward explosion over 1.5 s, then fade) +- Rank E badge (`RankBadge("E", Color(0xFF4FC3F7), 120.dp)`) materializes at center with pulsing glow ring +- Text animates in below badge: + - `"HUNTER [userName]"` (gold, large) + - `"REGISTRATION COMPLETE"` (electric blue, medium, letter-spaced) + - `"THE SYSTEM ACKNOWLEDGES YOUR EXISTENCE."` (muted, small) + - `"YOUR JOURNEY BEGINS NOW."` (muted, small) +- CTA: `[ ARISE ]` — wide glowing button, electric blue gradient, shadow glow + - On tap: + 1. `OnboardingViewModel.completeOnboarding()` → saves all draft fields atomically + 2. `onboardingComplete = true` in `AppDataState` + 3. Navigate to `"Tabs"`, `popUpTo("Onboarding") { inclusive = true }` + 4. Post-navigation: show widget add bottom sheet (see below) + +**Widget prompt (bottom sheet, shown after navigation):** +- Triggered by `LaunchedEffect` in `HomeScreen` when `justCompletedOnboarding = true` +- Title: `"Add IronLog to Your Home Screen"` +- Body: `"Quick-start workouts and check your streak without opening the app."` +- Button: `"ADD WIDGET"` → deep link to widget picker intent +- Dismiss: `"Maybe Later"` link + +--- + +## OnboardingConfig.kt — Single Source of Truth + +All copy, colors, step ordering, and badge definitions must live in this file: + +```kotlin +object OnboardingConfig { + val steps: List = listOf( + OnboardingStep.Awakening, + OnboardingStep.Registration, + OnboardingStep.Classification, + OnboardingStep.Quota, + OnboardingStep.GoalMode, + OnboardingStep.AiAbilities, + OnboardingStep.Permissions, + OnboardingStep.Arise, + ) + + val accentBlue = Color(0xFF4FC3F7) + val accentGold = Color(0xFFFFD700) + val backgroundDark = Color(0xFF050508) + val surfaceDark = Color(0xFF0D0D14) + val cardBorder = Color(0xFF1A1A2E) + + // AI model options + val geminiModels = listOf( + AiModelOption("gemini-2.0-flash", "Gemini 2.0 Flash", "Free · 1,500 req/day · 1M ctx"), + AiModelOption("gemini-2.5-flash", "Gemini 2.5 Flash", "Free · 250 req/day · 250K ctx"), + AiModelOption("gemini-2.5-pro", "Gemini 2.5 Pro", "Free · 100 req/day · 1M ctx"), + ) + + val geminiKeyPrefix = "AIza" + val aistudioUrl = "https://aistudio.google.com/app/apikey" +} +``` + +--- + +## Badge Definitions + +All 16 badges from the approved badge sheet are defined in `BadgeDefinitions.kt`: + +```kotlin +enum class BadgeTier { BRONZE, SILVER, GOLD, BLUE } + +data class BadgeDefinition( + val id: String, + val title: String, + val description: String, + val tier: BadgeTier, + val iconResId: Int, // vector drawable resource + val unlockCondition: (AppStats) -> Boolean, +) +``` + +| id | Title | Tier | Icon | Unlock Condition | +|----|-------|------|------|-----------------| +| `first_workout` | Iron Initiate | BRONZE | dumbbell | 1 workout logged | +| `streak_3` | Spark | BRONZE | flame | 3-day workout streak | +| `workouts_10` | Charged | SILVER | lightning | 10 workouts total | +| `consistency_4w` | Clockwork | SILVER | calendar | Worked out 4 weeks in a row | +| `first_pr` | Muscle Memory | SILVER | flexed arm | First personal record logged | +| `workouts_50` | Champion | GOLD | trophy | 50 workouts total | +| `streak_30` | Ironclad | GOLD | shield | 30-day streak | +| `workouts_100` | Sovereign | GOLD | crown | 100 workouts total | +| `member_365` | Eternal | BLUE | infinity | 365 days since first workout | +| `all_goal_modes` | Multiclass | BLUE | 3 stars | Used all 4 goal modes | +| `ai_activated` | Augmented | SILVER | atom | Cloud AI key entered | +| `volume_milestone` | Summit | GOLD | mountain | Lifted 100,000 kg total volume | +| `first_rest_timer` | Patience | BRONZE | hourglass | Used rest timer first time | +| `first_plan` | Architect | BRONZE | twin dumbbells | Created first training plan | +| `progressive_streak` | Growth Curve | SILVER | chart | 4 consecutive workouts with progression | +| `s_rank` | Diamond | BLUE | diamond | S-Rank achieved (defined by XP threshold in gamification plan) | + +--- + +## Settings Fields Written + +| Field | Type | Screen | +|-------|------|--------| +| `userName` | String | 2 | +| `progressionStyle` | Enum | 3 | +| `goalMode` | Enum | 5 | +| `weeklyGoalDays` | Int | 4 | +| `weightUnit` | Enum | 4 | +| `cloudAiApiKey` | String (encrypted) | 6 | +| `cloudAiModelName` | String | 6 | +| `cloudAiProviderPreset` | Enum | 6 | +| `intelligenceMode` | Enum | 6 (AUTO if key set, LOCAL otherwise) | +| `onboardingComplete` | Boolean | 8 | + +--- + +## Animations & Effects Summary + +| Effect | Implementation | +|--------|----------------| +| Particle drift (screens 1 & 8) | Custom `Canvas` composable, `rememberInfiniteTransition` | +| Particle burst (screen 8) | `LaunchedEffect` driven `mutableStateListOf` | +| Logo shimmer sweep | Animated gradient mask, `Brush.linearGradient` with animated offset | +| Typewriter text | `LaunchedEffect` with `delay(40)` per character, `StringBuilder` | +| Card glow border | `Modifier.border(...)` with `animateColorAsState` | +| Pulsing glow ring | `rememberInfiniteTransition`, `animateFloat` 0.4f→1.0f, 900ms Reverse | +| Screen transition | `HorizontalPager` default slide | +| Permission chip state | `animateColorAsState(tween(300))` | + +--- + +## Testing Requirements + +- Unit: `OnboardingViewModelTest` — all state transitions, `completeOnboarding()` saves all fields +- Unit: `OnboardingConfigTest` — steps list non-empty, all required fields present +- Integration: `OnboardingFlowTest` — full screen-to-screen navigation, final state verification +- Permission handling: mocked `ActivityResultLauncher` for all three permission types diff --git a/docs/superpowers/specs/2026-05-27-watermelon-rename-reorganize-design.md b/docs/superpowers/specs/2026-05-27-watermelon-rename-reorganize-design.md new file mode 100644 index 0000000..8da289a --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-watermelon-rename-reorganize-design.md @@ -0,0 +1,140 @@ +# Design: Watermelon Rename + Codebase Reorganization + +**Date:** 2026-05-27 +**Status:** Approved (user selected Option C) +**Scope:** Rename all WatermelonDB-derived identifiers to meaningful Kotlin names; reorganize `ui/screens/` into feature subdirectories; consolidate ObjectBox entity files; relocate `ExerciseTrackingTypeNormalizer`. + +--- + +## 1. Background + +IronLog was migrated from React Native (using WatermelonDB) to Kotlin. The migration left behind "Watermelon"-prefixed class names, internal function names, and code comments that refer to the old architecture. These names are noise — they carry no meaning in the Kotlin codebase and mislead readers (and AI assistants) about the actual architecture. + +Additionally, `ui/screens/` has grown to 32 flat files, making it slow to navigate and easy to open the wrong file. Feature subdirectories group related screens together so any screen is findable in 1–2 steps. + +--- + +## 2. Rename Map + +### 2a. ViewModel Files and Classes + +All files live in `ui/viewmodel/`. File renames require updating both the filename and the class declaration inside. + +| Old filename | New filename | Old class(es) | New class(es) | +|---|---|---|---| +| `WatermelonAppDataViewModel.kt` | `AppDataViewModel.kt` | `WatermelonAppDataViewModel` | `AppDataViewModel` | +| `WatermelonStatsViewModel.kt` | `StatsViewModel.kt` | `WatermelonStatsViewModel` | `StatsViewModel` | +| `WatermelonPlansViewModel.kt` | `PlansViewModel.kt` | `WatermelonPlansViewModel` | `PlansViewModel` | +| `WatermelonBodyMeasurementsViewModel.kt` | `BodyMeasurementsViewModel.kt` | `WatermelonBodyMeasurementsViewModel` | `BodyMeasurementsViewModel` | +| | | `WatermelonBodyMeasurementsViewModelFactory` | `BodyMeasurementsViewModelFactory` | + +All call sites across all screens and navigation must be updated to use the new class names. The package declaration (`com.ironlog.app.ui.viewmodel`) stays unchanged. + +### 2b. `ui/state/WorkoutContextPort.kt` → `ui/state/WorkoutState.kt` + +File rename only. The "Port" suffix is an RN migration leftover. Classes inside (`WorkoutState`, `WorkoutAction`, `WorkoutInput`, etc.) are already correctly named — no class renames needed. The package stays `com.ironlog.app.ui.state`. All imports across the codebase reference the class names directly, not the filename, so no import changes are needed (Kotlin imports by class, not file). + +### 2c. `data/repository/ImportExportRepository.kt` — Internal Renames + +These are private/internal identifiers — they affect only the file itself. + +| Old identifier | New identifier | Notes | +|---|---|---| +| `fun isWatermelonExport()` | `fun isIronLogExport()` | Private function | +| `fun convertLegacyToWatermelonExport()` | `fun convertLegacyToIronLogExport()` | Private function | +| `"watermelon_v1"` (internal format tag) | `"ironlog_v1"` | Used in lines ~207, ~316; internal only, not stored in user files | + +**Backward compat preserved:** +`const val EXPORT_TYPE = "ironlog_watermelon_export"` — this string literal is embedded in every backup file ever written by the app. Its **value must not change**. Only the surrounding Kotlin function names change. `BackupCenterScreen.kt` line 304 checks `obj.optString("type") == "ironlog_watermelon_export"` — this check stays exactly as-is. + +### 2d. Comment Cleanup (No Behavioral Changes) + +| File | Old comment text | New comment text | +|---|---|---| +| `data/objectbox/Entities.kt` | `/** Watermelon table: X */` (on each entity class) | `/** ObjectBox entity: X */` | +| `data/objectbox/ObjectBox.kt` | Comment referencing "WatermelonDB database.js equivalent" | Reword to describe ObjectBox store role | +| `data/repository/BodyMeasurementRepository.kt` | Comment referencing WatermelonDB observe() | Reword to describe ObjectBox flow | +| `ui/model/UiModels.kt` | Comment referencing React screens and Watermelon rows | Reword to describe Kotlin/Compose usage | +| All ViewModel files | Class-level comment `"Kotlin replacement for useWatermelonXxx.js"` | Remove or replace with a brief description of the class's actual role | + +--- + +## 3. `ui/screens/` Feature Subdirectory Split + +Each subdirectory gets its own package declaration (`com.ironlog.app.ui.screens.`). All import statements across the codebase that reference screen classes must be updated to the new package. + +### Directory mapping + +| Subdirectory | Files | New package | +|---|---|---| +| `home/` | `HomeScreen.kt` | `com.ironlog.app.ui.screens.home` | +| `workout/` | `ActiveWorkoutScreen.kt`, `WorkoutCalendarScreen.kt`, `WorkoutExerciseBinding.kt` | `com.ironlog.app.ui.screens.workout` | +| `plans/` | `PlansScreen.kt`, `PlanEditorScreen.kt`, `ProgramPickerScreen.kt`, `ProgramInsightsScreen.kt`, `AIPlanScreen.kt`, `PlanQrScanScreen.kt`, `PlanQrShareSheet.kt` | `com.ironlog.app.ui.screens.plans` | +| `body/` | `BodyWeightScreen.kt`, `BodyMeasurementsScreen.kt`, `BodyMapCanvas.kt`, `ProgressPhotosScreen.kt` | `com.ironlog.app.ui.screens.body` | +| `stats/` | `StatsScreen.kt`, `HistoryScreen.kt`, `ExerciseProgressScreen.kt`, `VolumeAnalyticsScreen.kt` | `com.ironlog.app.ui.screens.stats` | +| `recovery/` | `RecoveryMapScreen.kt`, `RecoveryHeatmapCard.kt`, `RecoveryCircuitSheet.kt` | `com.ironlog.app.ui.screens.recovery` | +| `intelligence/` | `TrainingIntelligenceScreen.kt`, `ApexEngineCard.kt`, `CloudAiCard.kt` | `com.ironlog.app.ui.screens.intelligence` | +| `gamification/` | `StatusWindowScreen.kt` | `com.ironlog.app.ui.screens.gamification` | +| `settings/` | `SettingsScreen.kt`, `GymProfilesScreen.kt`, `GymProfileEditorScreen.kt`, `CreateExerciseScreen.kt`, `ExerciseLibraryScreen.kt`, `DataPortabilityScreen.kt`, `ImportCenterScreen.kt`, `BackupCenterScreen.kt`, `RestoreDataScreen.kt`, `HealthConnectScreen.kt`, `HealthConnectPermissionSheet.kt`, `PrivacyScreen.kt` | `com.ironlog.app.ui.screens.settings` | +| `onboarding/` | Already organized with its own `steps/` subdir — **leave as-is** | `com.ironlog.app.ui.screens.onboarding` (unchanged) | +| *(root)* | `SplashScreen.kt` — leave at `ui/screens/` root | `com.ironlog.app.ui.screens` (unchanged) | + +**Primary callers to update:** `AppNavigator.kt` (imports all screens for navigation graph) and any composable that hosts another screen directly. + +--- + +## 4. Entity File Consolidation + +`data/entity/` (3 gamification entity files) merges into `data/objectbox/`. All ObjectBox entities live in one place. + +### Files moved + +| Old path | New path | Package change | +|---|---|---| +| `data/entity/AthleteCalibrationEntity.kt` | `data/objectbox/AthleteCalibrationEntity.kt` | `com.ironlog.app.data.entity` → `com.ironlog.app.data.objectbox` | +| `data/entity/GamificationProfileEntity.kt` | `data/objectbox/GamificationProfileEntity.kt` | same | +| `data/entity/IronLedgerEventEntity.kt` | `data/objectbox/IronLedgerEventEntity.kt` | same | + +After the move, the `data/entity/` directory is deleted (it will be empty). + +All files that import `com.ironlog.app.data.entity.*` or individual entity classes must be updated to `com.ironlog.app.data.objectbox.*`. Likely callers: `ObjectBox.kt` (entity registration), gamification domain files, repositories that read/write these entities. + +--- + +## 5. `ExerciseTrackingTypeNormalizer` Relocation + +| Old path | New path | Package change | +|---|---|---| +| `data/exercise/ExerciseTrackingTypeNormalizer.kt` | `util/ExerciseTrackingTypeNormalizer.kt` | `com.ironlog.app.data.exercise` → `com.ironlog.app.util` | + +After the move, `data/exercise/` is deleted (it will be empty). All callers that import `com.ironlog.app.data.exercise.ExerciseTrackingTypeNormalizer` must be updated to `com.ironlog.app.util.ExerciseTrackingTypeNormalizer`. + +--- + +## 6. What Does NOT Change + +- `const val EXPORT_TYPE = "ironlog_watermelon_export"` — value stays exactly as-is. +- The string `"ironlog_watermelon_export"` in `BackupCenterScreen.kt` line ~304 — stays as-is. +- `onboarding/` subdir — already organized; left untouched. +- `SplashScreen.kt` — stays at `ui/screens/` root (it's the one screen that precedes any feature context). +- All class names that were already correct (`WorkoutState`, `WorkoutAction`, `PlansViewModel` internal logic, etc.) — no changes. +- No React Native, JS, TS, or Expo files are touched. +- No changes to build system, Gradle scripts, or manifest beyond what's required by package changes. + +--- + +## 7. Verification Strategy + +1. After all renames and moves: `.\gradlew.bat assembleDebug --no-daemon` → must exit with `BUILD SUCCESSFUL`. +2. Zero Kotlin compile errors — all import paths resolve. +3. No references to old class names remain in `.kt` files (grep check post-build). +4. `EXPORT_TYPE` value unchanged (grep check: `"ironlog_watermelon_export"` still present in `ImportExportRepository.kt`). + +--- + +## 8. Out of Scope + +- Renaming `IronLogApplication`, `MainActivity`, or any other non-Watermelon class. +- Changing ObjectBox schema version or entity annotations. +- Updating navigation route strings (they reference composable function names, not class names). +- Any refactoring of business logic. diff --git a/docs/superpowers/specs/2026-06-07-forge-fox-widget-polish-design.md b/docs/superpowers/specs/2026-06-07-forge-fox-widget-polish-design.md new file mode 100644 index 0000000..70bfe34 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-forge-fox-widget-polish-design.md @@ -0,0 +1,19 @@ +# Forge Fox Widget Polish Design + +**Goal:** Turn the current functional Forge Fox widgets from placeholder-looking cards into a cleaner, more intentional first production pass. + +**Approved Direction:** The existing implementation is useful for data wiring, but visually weak. The polish pass will keep Jetpack Glance, the existing widget receivers, and the existing `WidgetState` data flow. It will improve the visual result without rewriting app architecture. + +## Design + +The widget system keeps one consistent grammar: a streak lockup at the top-left with the official flaming dumbbell icon immediately left of the streak number, bold state text below, and Forge Fox composed for each size. The lockup remains anchored in all sizes. The mascot art remains raster PNG for now because the available 2D chibi poses are bitmap assets; the streak icon becomes the extracted reference-based PNG instead of the rough temporary vector. + +Small widgets should be simple: icon, number, short label, and a cropped mascot face or readable upper body. Medium widgets can show a weekly row, but it should be a compact strip in its own translucent band instead of raw dots floating near the edge. Tall widgets should use stronger vertical composition with a larger mascot and weekly row at the bottom. Wide widgets should stop reading like stretched squares: left column for lockup/status, right side for Forge Fox, bottom row only when it fits. + +## Implementation Boundaries + +Modify only the widget implementation, widget tests, and preview artifacts unless compilation requires a local import fix. Do not change ObjectBox schema, app navigation, workout completion, or gamification logic outside the existing widget resolver hook. + +## Verification + +Unit tests should prove the state resolver returns active streak for a safe streak state, the layout classifier chooses the expected size classes, and the single streak icon resource remains the official `ic_forge_streak_dumbbell`. Build verification should run `:app:testDebugUnitTest` and `:app:assembleDebug`. A generated local preview board should be produced because no emulator is currently attached. diff --git a/features/.gitkeep b/features/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/features/screenshots/.gitkeep b/features/screenshots/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/features/screenshots/full/.gitkeep b/features/screenshots/full/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/fixes.css b/fixes.css deleted file mode 100644 index 75bcf69..0000000 --- a/fixes.css +++ /dev/null @@ -1,488 +0,0 @@ -/* GitHub Pages stability and responsive layout fixes */ - -:root { - --safe-page-width: min(1180px, calc(100vw - 32px)); - --header-x-padding: max(16px, calc((100vw - 1180px) / 2)); -} - -html, -body { - width: 100%; - max-width: 100%; -} - -img, -svg, -video, -canvas { - max-width: 100%; -} - -button, -a, -input { - touch-action: manipulation; -} - -button:focus-visible, -a:focus-visible, -input:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 3px; -} - -.hero-shell, -.section-band, -.widgets-band, -.kotlin-band, -.ledger-band, -.status-band, -.roadmap-band, -.site-footer { - width: var(--safe-page-width); -} - -.site-header { - width: 100%; - max-width: none; - margin: 0; - padding: 12px var(--header-x-padding); - background: rgba(11, 19, 38, 0.72); - border-bottom: 1px solid rgba(218, 226, 253, 0.08); - -webkit-backdrop-filter: blur(18px) saturate(1.15); - backdrop-filter: blur(18px) saturate(1.15); -} - -.brand-mark { - align-items: center; - line-height: 1; -} - -.brand-glyph { - position: relative; - width: 34px; - height: 34px; - flex: 0 0 34px; - padding: 0; - border: 0; - border-radius: 0; - background: transparent; - box-shadow: none; - overflow: visible; -} - -.brand-glyph img { - display: block; - width: 100%; - height: 100%; - object-fit: contain; - transform: none; -} - -.brand-glyph::before { - content: none; -} - -.hero-note { - max-width: 560px; - margin: 14px 0 0; - color: var(--muted); - font-size: 15px; - line-height: 1.7; -} - -.demo-stage { - justify-self: center; - width: 100%; -} - -.primary-action { - border-color: rgba(210, 187, 255, 0.72); -} - -.control-button:hover { - border-color: rgba(210, 187, 255, 0.62); -} - -.screen-fox { - object-fit: contain; -} - -.fox-widget { - position: relative; - overflow: hidden; -} - -.fox-widget > img { - opacity: 0; -} - -.fox-widget::after { - content: ""; - position: absolute; - right: 18px; - bottom: 0; - width: min(52%, 210px); - height: 82%; - background-position: right bottom; - background-repeat: no-repeat; - background-size: contain; - pointer-events: none; - z-index: 1; -} - -.fox-widget.widget-hot::after { - background-image: url("assets/site/chibi_streak.webp"); -} - -.fox-widget.widget-calm::after { - background-image: url("assets/site/chibi_dumbbell.webp"); -} - -.fox-widget.widget-alert::after { - background-image: url("assets/site/chibi_watch.webp"); -} - -.fox-widget .widget-score, -.fox-widget p { - position: relative; - z-index: 2; -} - -.feature-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); -} - -.feature-grid .system-card { - min-height: 280px; -} - -.status-band, -.roadmap-band { - margin: 0 auto; - padding: 84px 0; -} - -.status-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 14px; -} - -.status-card, -.roadmap-list div { - border: 1px solid rgba(218, 226, 253, 0.12); - border-radius: var(--radius); - background: rgba(23, 31, 51, 0.72); -} - -.status-card { - padding: 20px; - min-height: 210px; - transform: translateY(18px); - opacity: 0; - transition: transform 520ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 520ms ease; -} - -.status-card.is-visible { - transform: translateY(0); - opacity: 1; -} - -.status-card span, -.roadmap-list strong { - color: var(--accent); - font-size: 12px; - font-weight: 900; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.status-card strong { - display: block; - margin-top: 20px; - font-size: 24px; - line-height: 1.1; -} - -.status-card p { - margin: 14px 0 0; - color: var(--subtext); - font-size: 14px; - line-height: 1.6; -} - -.roadmap-band { - display: grid; - grid-template-columns: minmax(0, 0.82fr) minmax(340px, 1fr); - gap: 24px; - align-items: start; - border-top: 1px solid rgba(218, 226, 253, 0.12); -} - -.roadmap-band > div:first-child { - max-width: 620px; -} - -.roadmap-band h2 { - margin: 0; - font-size: clamp(34px, 5vw, 62px); - line-height: 1.02; - font-weight: 950; -} - -.roadmap-band p { - color: var(--subtext); - font-size: 17px; - line-height: 1.65; -} - -.roadmap-list { - display: grid; - gap: 10px; -} - -.roadmap-list div { - display: flex; - justify-content: space-between; - gap: 18px; - padding: 16px; - color: var(--text); -} - -.roadmap-list span { - color: var(--subtext); -} - -.roadmap-band .primary-action { - justify-self: start; - grid-column: 1 / -1; -} - -@media (max-width: 1180px) { - :root { - --header-x-padding: 16px; - } - - .hero-shell { - grid-template-columns: minmax(0, 0.92fr) minmax(420px, 1.08fr); - } - - .demo-stage { - grid-template-columns: minmax(300px, 410px) minmax(210px, 280px); - } - - .feature-grid, - .status-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 980px) { - :root { - --safe-page-width: min(760px, calc(100vw - 32px)); - } - - .hero-shell { - min-height: auto; - align-items: start; - } - - .demo-stage { - max-width: 560px; - margin-inline: auto; - } - - .demo-controls { - min-height: unset; - } - - .system-grid:not(.feature-grid), - .widget-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .system-grid:not(.feature-grid) .system-card:last-child, - .fox-widget:last-child { - grid-column: 1 / -1; - } - - .roadmap-band { - grid-template-columns: 1fr; - } -} - -@media (max-width: 720px) { - :root { - --safe-page-width: min(560px, calc(100vw - 24px)); - --header-x-padding: 12px; - } - - .site-header { - min-height: 64px; - padding-block: 10px; - } - - .brand-glyph { - width: 32px; - height: 32px; - flex-basis: 32px; - } - - .hero-shell { - padding: 18px 0 54px; - } - - .section-band, - .widgets-band, - .kotlin-band, - .ledger-band, - .status-band, - .roadmap-band { - padding: 58px 0; - } - - .system-grid, - .widget-grid, - .status-grid, - .system-card:last-child, - .fox-widget:last-child { - grid-template-columns: 1fr; - grid-column: auto; - } - - .system-card, - .feature-grid .system-card { - min-height: 190px; - } - - .system-card h3 { - margin-top: 44px; - } - - .roadmap-list div { - flex-direction: column; - gap: 6px; - } - - .kotlin-band, - .site-footer { - flex-direction: column; - align-items: flex-start; - } -} - -@media (max-width: 620px) { - :root { - --safe-page-width: calc(100vw - 22px); - } - - .hero-shell, - .section-band, - .widgets-band, - .kotlin-band, - .ledger-band, - .status-band, - .roadmap-band, - .site-footer { - width: var(--safe-page-width); - max-width: var(--safe-page-width); - } - - .site-header { - width: 100%; - max-width: none; - } - - .nav-links { - display: none; - } - - .hero-copy h1 { - font-size: clamp(50px, 20vw, 76px); - } - - .hero-lede { - font-size: 17px; - } - - .phone-shell { - width: var(--safe-page-width); - max-width: var(--safe-page-width); - min-height: auto; - } - - .phone-screen { - min-height: 540px; - } - - .metric-panel { - grid-template-columns: 58px minmax(0, 1fr); - gap: 10px; - padding: 12px; - } - - .metric-value { - min-width: 0; - font-size: 40px; - } - - .lift-row, - .signal-meter { - grid-template-columns: 62px minmax(0, 1fr) 36px; - gap: 8px; - } - - .phone-nav { - gap: 4px; - padding: 5px; - } - - .phone-nav button { - font-size: 10px; - padding-inline: 2px; - } - - .demo-controls { - padding: 16px; - } - - .demo-controls h2 { - font-size: 24px; - } - - .fox-widget { - min-height: 320px; - } - - .fox-widget::after { - width: 58%; - height: 78%; - right: 8px; - } -} - -@media (max-width: 380px) { - .brand-mark span:last-child { - display: none; - } - - .brand-glyph { - width: 30px; - height: 30px; - flex-basis: 30px; - } - - .screen-top { - padding-right: 68px; - } - - .screen-fox { - width: 64px; - height: 64px; - } - - .phone-nav button { - font-size: 9px; - } -} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..9684abc --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.java.home=C:/Program Files/Eclipse Adoptium/jdk-21.0.11.10-hotspot +android.useAndroidX=true +android.enableJetifier=true +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4f5eb9d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..7f94d3d --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/index.html b/index.html deleted file mode 100644 index b7b0b6f..0000000 --- a/index.html +++ /dev/null @@ -1,269 +0,0 @@ - - - - - - - - - - - - - - IronLog - Local-first Android Workout Tracker - - - - - - - - - -
-
-
-

Native Android rebuild in progress

-

IronLog

-

- A local-first Android workout tracker for logging lifts, tracking recovery, and keeping streaks honest. -

-

- Built for lifters who want fast training records, visible progression, and no cloud dependency. -

- -
- Local-first - Kotlin rebuild - ObjectBox storage - Widget system -
-
- -
-
-
- 9:41 - -
-
-
-
-

Daily proof

-

Ready to train

-
- Forge Fox mascot -
- -
- 92 -
- Fresh - Push volume today. Ledger proof is available. -
-
- -
- -
- - - - -
-
-
- - -
-
- -
-
-

Core features

-

Simple logging first. Gamification second.

-

- IronLog is designed around the actions a lifter repeats every week: record work, judge readiness, protect consistency, and see progression. -

-
-
-
- 01 -

Workout logging

-

Track exercises, sets, reps, load, and session quality without slowing down the workout.

-
-
- 02 -

Recovery map

-

Surface muscle readiness and workload patterns so the next session is based on context, not guesswork.

-
-
- 03 -

Iron Ledger

-

Ranks, levels, XP, badges, and integrity stay tied to verified training history.

-
-
- 04 -

Home widgets

-

Streak, readiness, and proof-window widgets make the app useful without opening the full tracker.

-
-
-
- -
-
-

Project status

-

Clear about what exists and what is being rebuilt.

-

- The public page is a live product preview. The Kotlin Android source is being prepared before public release. -

-
-
-
- Current phase - Kotlin rebuild -

Legacy public build cleared. Native Android implementation is being prepared.

-
-
- Platform - Android -

Designed for a native mobile experience, not a wrapped web app.

-
-
- Data model - Local-first -

Training history and progression are planned around device-side storage.

-
-
- Public release - Not released yet -

The interactive page is a preview while the app implementation is cleaned up.

-
-
-
- -
-
-

Progression system

-

Ranks stay earned.

-

- Graphite, Iron, Steel, Titanium, Obsidian, Iridium, Apex, and Aether are designed to reward consistency, logged work, and proof integrity. -

-
- 14 day trend - 7 signals - 99 readiness -
-
-
- Iron grade badge - Titanium grade badge -
-
- -
-
-

Home screen widgets

-

Forge Fox keeps the streak visible.

-

- Mascot-led widgets make the app glanceable: one metric, one mood, one action. -

-
-
-
- - - 7 - -

Keep it going.

- Forge Fox streak widget pose -
-
- - - 92 - -

Train today.

- Forge Fox dumbbell widget pose -
-
- - - 1h - -

Proof window closing.

- Forge Fox watch widget pose -
-
-
- -
-
-

Active rebuild roadmap

-

What is next.

-

- The rebuild priority is a usable workout logger before expanding into recovery scoring, ledger progression, and widgets. -

-
-
-
Workout logger MVPIn progress
-
ObjectBox local storagePlanned
-
Recovery readiness modelPrototype
-
Iron Ledger ranksDesigned
-
Public APK / release buildNot released
-
- Watch the repo -
-
- -
- IronLog - Local-first Android workout tracker -
- - - - diff --git a/plugins/.gitkeep b/plugins/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/caveman/.codex-plugin/.gitkeep b/plugins/caveman/.codex-plugin/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/caveman/.gitkeep b/plugins/caveman/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/caveman/assets/.gitkeep b/plugins/caveman/assets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/caveman/skills/.gitkeep b/plugins/caveman/skills/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/caveman/skills/caveman/.gitkeep b/plugins/caveman/skills/caveman/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/caveman/skills/caveman/agents/.gitkeep b/plugins/caveman/skills/caveman/agents/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/caveman/skills/caveman/assets/.gitkeep b/plugins/caveman/skills/caveman/assets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/caveman/skills/compress/.gitkeep b/plugins/caveman/skills/compress/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/caveman/skills/compress/scripts/.gitkeep b/plugins/caveman/skills/compress/scripts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/.gitkeep b/scripts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..ea1b677 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "IronLogKotlinPort" +include(":app") diff --git a/src/.gitkeep b/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/components/.gitkeep b/src/components/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/components/ui/.gitkeep b/src/components/ui/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/context/.gitkeep b/src/context/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/data/.gitkeep b/src/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/db/.gitkeep b/src/db/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/db/adapters/.gitkeep b/src/db/adapters/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/db/models/.gitkeep b/src/db/models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/db/repositories/.gitkeep b/src/db/repositories/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/db/seed/.gitkeep b/src/db/seed/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/domain/.gitkeep b/src/domain/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/domain/intelligence/.gitkeep b/src/domain/intelligence/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/hooks/.gitkeep b/src/hooks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/navigation/.gitkeep b/src/navigation/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/platform/.gitkeep b/src/platform/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/screens/.gitkeep b/src/screens/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/services/.gitkeep b/src/services/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/utils/.gitkeep b/src/utils/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/styles.css b/styles.css deleted file mode 100644 index cd7393c..0000000 --- a/styles.css +++ /dev/null @@ -1,1043 +0,0 @@ -@font-face { - font-family: "Lexend"; - src: url("assets/fonts/lexend_variable.ttf") format("truetype"); - font-weight: 100 900; - font-display: swap; -} - -:root { - color-scheme: dark; - --bg: #0b1326; - --bg-2: #0f172d; - --card: #171f33; - --card-2: #1d2740; - --border: #2d3449; - --text: #dae2fd; - --subtext: #c9c4e6; - --muted: #958da1; - --accent: #d2bbff; - --accent-2: #6bd8cb; - --gold: #d9bf74; - --danger: #ffb4ab; - --success: #bfcfff; - --shadow: 0 28px 80px rgba(0, 0, 0, 0.38); - --radius: 8px; -} - -[data-site-theme="teal"] { - --accent: #6bd8cb; - --accent-2: #7bd0ff; - --gold: #d2c37d; - --bg: #081824; - --bg-2: #0e2230; -} - -[data-site-theme="graphite"] { - --accent: #f3f4f2; - --accent-2: #aaccee; - --gold: #cacaca; - --bg: #111; - --bg-2: #181818; - --card: #232323; - --card-2: #2b2b2b; - --border: #3d3d3d; -} - -* { - box-sizing: border-box; -} - -html { - scroll-behavior: smooth; -} - -body { - margin: 0; - min-width: 320px; - overflow-x: hidden; - background: - linear-gradient(rgba(210, 187, 255, 0.055) 1px, transparent 1px), - linear-gradient(90deg, rgba(107, 216, 203, 0.045) 1px, transparent 1px), - var(--bg); - background-size: 42px 42px, 42px 42px, auto; - color: var(--text); - font-family: "Lexend", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - letter-spacing: 0; -} - -body::before { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - background: - linear-gradient(180deg, rgba(11, 19, 38, 0.08), var(--bg) 82%), - repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.018), rgba(255, 255, 255, 0.018) 1px, transparent 1px, transparent 5px); - z-index: -1; -} - -a { - color: inherit; - text-decoration: none; -} - -button, -a { - -webkit-tap-highlight-color: transparent; -} - -button { - font: inherit; -} - -.site-header { - position: sticky; - top: 0; - z-index: 50; - display: flex; - align-items: center; - justify-content: space-between; - gap: 18px; - width: min(1180px, calc(100% - 32px)); - min-height: 72px; - margin: 0 auto; - padding: 14px 0; - backdrop-filter: blur(18px); -} - -.brand-mark, -.header-cta, -.primary-action, -.secondary-action, -.control-button { - min-height: 44px; -} - -.brand-mark { - display: inline-flex; - align-items: center; - gap: 12px; - font-weight: 900; - font-size: 18px; -} - -.brand-glyph { - display: grid; - place-items: center; - width: 54px; - height: 42px; - border: 1px solid rgba(210, 187, 255, 0.34); - border-radius: var(--radius); - background: #000; - box-shadow: 0 12px 34px rgba(0, 0, 0, 0.22); - overflow: hidden; -} - -.brand-glyph img { - width: 100%; - height: 100%; - object-fit: contain; - transform: scale(1.72); -} - -.nav-links { - display: flex; - align-items: center; - gap: 8px; - color: var(--subtext); - font-size: 14px; -} - -.nav-links a, -.header-cta { - border-radius: var(--radius); - padding: 12px 14px; -} - -.nav-links a:hover, -.header-cta:hover, -.secondary-action:hover { - background: rgba(210, 187, 255, 0.12); -} - -.header-cta { - border: 1px solid rgba(210, 187, 255, 0.28); - color: var(--accent); - font-size: 13px; - font-weight: 800; -} - -.hero-shell { - display: grid; - grid-template-columns: minmax(0, 0.86fr) minmax(460px, 1.14fr); - gap: clamp(30px, 5vw, 70px); - align-items: center; - min-height: min(880px, calc(100svh - 72px)); - width: min(1180px, calc(100% - 32px)); - margin: 0 auto; - padding: 38px 0 72px; -} - -.hero-copy { - min-width: 0; - max-width: 560px; -} - -.eyebrow, -.micro-label { - margin: 0 0 12px; - color: var(--accent); - font-size: 12px; - font-weight: 900; - letter-spacing: 0.22em; - text-transform: uppercase; -} - -.hero-copy h1 { - margin: 0; - font-size: clamp(58px, 9vw, 118px); - line-height: 0.92; - font-weight: 950; - letter-spacing: 0; -} - -.hero-lede { - max-width: 540px; - margin: 24px 0 0; - color: var(--subtext); - font-size: clamp(18px, 2vw, 24px); - line-height: 1.48; - overflow-wrap: anywhere; -} - -.hero-actions { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin-top: 30px; -} - -.primary-action, -.secondary-action { - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: var(--radius); - padding: 14px 18px; - font-weight: 900; -} - -.primary-action { - border: 1px solid color-mix(in srgb, var(--accent) 72%, white 8%); - background: var(--accent); - color: #24133f; -} - -.primary-action.compact { - white-space: nowrap; -} - -.secondary-action { - border: 1px solid var(--border); - background: rgba(23, 31, 51, 0.72); - color: var(--text); -} - -.signal-row { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 26px; -} - -.signal-row span { - border: 1px solid rgba(107, 216, 203, 0.24); - border-radius: var(--radius); - padding: 9px 11px; - background: rgba(107, 216, 203, 0.08); - color: var(--subtext); - font-size: 12px; - font-weight: 750; -} - -.demo-stage { - display: grid; - grid-template-columns: minmax(310px, 430px) minmax(220px, 300px); - gap: 18px; - align-items: stretch; - min-width: 0; -} - -.hero-shell > *, -.demo-stage > * { - min-width: 0; -} - -.phone-shell { - position: relative; - width: 100%; - max-width: 100%; - min-height: 720px; - border: 1px solid rgba(218, 226, 253, 0.16); - border-radius: 32px; - padding: 14px; - background: #050b16; - box-shadow: var(--shadow); - overflow: hidden; -} - -.phone-shell::before { - content: ""; - position: absolute; - inset: 10px; - border: 1px solid rgba(210, 187, 255, 0.14); - border-radius: 24px; - pointer-events: none; -} - -.phone-status { - position: relative; - z-index: 2; - display: flex; - align-items: center; - justify-content: space-between; - height: 30px; - padding: 0 18px; - color: var(--subtext); - font-size: 12px; - font-weight: 850; -} - -.status-dots, -.status-dots::before, -.status-dots::after { - display: block; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--accent); -} - -.status-dots { - position: relative; -} - -.status-dots::before, -.status-dots::after { - content: ""; - position: absolute; - top: 0; -} - -.status-dots::before { - right: 10px; -} - -.status-dots::after { - right: 20px; -} - -.phone-screen { - position: relative; - z-index: 1; - display: flex; - flex-direction: column; - min-width: 0; - min-height: 660px; - border-radius: 22px; - padding: 22px 18px 18px; - background: - linear-gradient(180deg, rgba(23, 31, 51, 0.86), rgba(11, 19, 38, 0.96)), - var(--bg); - overflow: hidden; -} - -.screen-top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 14px; - min-height: 104px; -} - -.screen-top > div { - min-width: 0; -} - -.screen-top h2 { - margin: 0; - font-size: 30px; - line-height: 1.05; - font-weight: 950; - overflow-wrap: anywhere; -} - -.screen-fox { - width: 112px; - height: 112px; - object-fit: contain; - filter: drop-shadow(0 16px 22px rgba(0, 0, 0, 0.34)); - transition: transform 420ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 260ms ease; -} - -.screen-fox.is-jumping { - transform: translateY(-12px) rotate(-3deg) scale(1.05); -} - -.metric-panel { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 14px; - align-items: center; - min-width: 0; - border: 1px solid rgba(210, 187, 255, 0.22); - border-radius: var(--radius); - padding: 14px; - background: rgba(210, 187, 255, 0.09); -} - -.metric-value { - min-width: 78px; - color: var(--accent); - font-size: 52px; - line-height: 0.95; - font-weight: 950; - text-align: center; -} - -.metric-panel strong { - display: block; - font-size: 18px; -} - -.metric-panel span:last-child { - display: block; - margin-top: 4px; - color: var(--subtext); - font-size: 12px; - line-height: 1.5; - overflow-wrap: anywhere; -} - -.demo-content { - flex: 1; - display: flex; - flex-direction: column; - gap: 12px; - padding: 14px 0; - transition: opacity 180ms ease, transform 180ms ease; - min-width: 0; -} - -.demo-content.is-swapping { - opacity: 0; - transform: translateY(8px); -} - -.app-card, -.mini-card { - border: 1px solid rgba(218, 226, 253, 0.12); - border-radius: var(--radius); - background: rgba(23, 31, 51, 0.84); - min-width: 0; -} - -.app-card { - padding: 14px; -} - -.app-card h3, -.mini-card h3 { - margin: 0 0 8px; - font-size: 14px; -} - -.app-card p, -.mini-card p { - margin: 0; - color: var(--subtext); - font-size: 12px; - line-height: 1.5; -} - -.progress-line { - height: 9px; - border-radius: 999px; - margin-top: 12px; - background: rgba(218, 226, 253, 0.1); - overflow: hidden; -} - -.progress-line span { - display: block; - width: var(--value, 70%); - height: 100%; - border-radius: inherit; - background: var(--accent); - transition: width 300ms ease; -} - -.mini-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} - -.mini-card { - padding: 12px; -} - -.lift-row, -.signal-meter { - display: grid; - grid-template-columns: 74px minmax(0, 1fr) 38px; - gap: 10px; - align-items: center; - padding: 9px 0; - color: var(--subtext); - font-size: 12px; -} - -.lift-row strong, -.signal-meter strong { - color: var(--text); -} - -.set-button, -.range-row input { - width: 100%; -} - -.set-button { - border: 1px solid rgba(210, 187, 255, 0.3); - border-radius: var(--radius); - padding: 13px; - background: var(--accent); - color: #24133f; - font-weight: 950; - cursor: pointer; -} - -.set-button:active, -.control-button:active, -.phone-nav button:active { - transform: translateY(1px); -} - -.range-row { - display: grid; - grid-template-columns: minmax(0, 1fr) 46px; - gap: 12px; - align-items: center; - margin-top: 12px; -} - -input[type="range"] { - accent-color: var(--accent); -} - -.body-map { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; - margin-top: 10px; -} - -.body-figure { - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-start; - gap: 7px; - min-height: 236px; - margin: 0; - padding: 10px 6px 8px; - border: 1px solid rgba(218, 226, 253, 0.1); - border-radius: var(--radius); - background: rgba(11, 19, 38, 0.48); - position: relative; - overflow: hidden; -} - -.body-figure::before { - content: ""; - position: absolute; - top: 10px; - bottom: 28px; - left: 50%; - width: 1px; - background: rgba(210, 187, 255, 0.16); -} - -.body-figure img { - width: 100%; - height: 202px; - object-fit: contain; - object-position: center bottom; - filter: drop-shadow(0 14px 16px rgba(0, 0, 0, 0.2)); - position: relative; - z-index: 1; -} - -.body-figure figcaption { - color: var(--subtext); - font-size: 10px; - font-weight: 900; - letter-spacing: 0.18em; - line-height: 1; - text-transform: uppercase; - position: relative; - z-index: 1; -} - -.ledger-preview { - display: grid; - grid-template-columns: 92px minmax(0, 1fr); - gap: 14px; - align-items: center; -} - -.ledger-preview img { - width: 92px; - height: 92px; - object-fit: contain; -} - -.phone-nav { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 6px; - border: 1px solid rgba(218, 226, 253, 0.12); - border-radius: var(--radius); - padding: 6px; - background: rgba(5, 11, 22, 0.82); - min-width: 0; -} - -.phone-nav button { - border: 0; - border-radius: 6px; - padding: 10px 4px; - background: transparent; - color: var(--muted); - font-size: 11px; - font-weight: 850; - cursor: pointer; - min-width: 0; -} - -.phone-nav button.is-active { - background: rgba(210, 187, 255, 0.16); - color: var(--accent); -} - -.demo-controls { - display: flex; - flex-direction: column; - justify-content: space-between; - gap: 18px; - border: 1px solid rgba(218, 226, 253, 0.12); - border-radius: var(--radius); - padding: 20px; - background: rgba(23, 31, 51, 0.62); - box-shadow: var(--shadow); -} - -.demo-controls h2 { - margin: 0; - font-size: 28px; - line-height: 1.1; -} - -.demo-controls p:not(.eyebrow) { - color: var(--subtext); - line-height: 1.55; -} - -.control-grid { - display: grid; - gap: 10px; -} - -.control-button { - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 12px; - background: rgba(11, 19, 38, 0.72); - color: var(--text); - font-weight: 850; - cursor: pointer; -} - -.control-button:hover { - border-color: color-mix(in srgb, var(--accent) 62%, var(--border)); -} - -.theme-switcher { - display: flex; - gap: 10px; -} - -.theme-dot { - width: 42px; - height: 42px; - border: 1px solid rgba(218, 226, 253, 0.22); - border-radius: var(--radius); - cursor: pointer; -} - -.theme-dot[data-theme-choice="amethyst"] { - background: #d2bbff; -} - -.theme-dot[data-theme-choice="teal"] { - background: #6bd8cb; -} - -.theme-dot[data-theme-choice="graphite"] { - background: #f3f4f2; -} - -.theme-dot.is-active { - outline: 2px solid var(--accent); - outline-offset: 3px; -} - -.section-band, -.widgets-band, -.kotlin-band, -.ledger-band { - width: min(1180px, calc(100% - 32px)); - margin: 0 auto; - padding: 84px 0; -} - -.section-heading { - max-width: 720px; - margin-bottom: 28px; -} - -.section-heading h2, -.ledger-copy h2, -.kotlin-band h2 { - margin: 0; - font-size: clamp(34px, 5vw, 62px); - line-height: 1.02; - font-weight: 950; -} - -.section-heading p:not(.eyebrow), -.ledger-copy p, -.kotlin-band p { - color: var(--subtext); - font-size: 17px; - line-height: 1.65; -} - -.system-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 14px; -} - -.system-card { - min-height: 260px; - border: 1px solid rgba(218, 226, 253, 0.12); - border-radius: var(--radius); - padding: 22px; - background: rgba(23, 31, 51, 0.72); - transform: translateY(18px); - opacity: 0; - transition: transform 520ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 520ms ease; -} - -.system-card.is-visible, -.fox-widget.is-visible, -.grade-showcase.is-visible { - transform: translateY(0); - opacity: 1; -} - -.card-number { - color: var(--gold); - font-weight: 950; -} - -.system-card h3 { - margin: 68px 0 12px; - font-size: 26px; -} - -.system-card p { - color: var(--subtext); - line-height: 1.6; -} - -.ledger-band { - display: grid; - grid-template-columns: minmax(0, 0.95fr) minmax(300px, 1.05fr); - gap: 26px; - align-items: center; -} - -.ledger-stats { - display: flex; - flex-wrap: wrap; - gap: 10px; - margin-top: 24px; -} - -.ledger-stats span { - border: 1px solid rgba(210, 187, 255, 0.2); - border-radius: var(--radius); - padding: 14px; - background: rgba(23, 31, 51, 0.76); - color: var(--subtext); -} - -.ledger-stats strong { - display: block; - color: var(--accent); - font-size: 30px; -} - -.grade-showcase { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; - transform: translateY(18px); - opacity: 0; - transition: transform 520ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 520ms ease; -} - -.grade-showcase img { - width: 100%; - aspect-ratio: 1; - object-fit: contain; - border: 1px solid rgba(218, 226, 253, 0.12); - border-radius: var(--radius); - padding: 24px; - background: rgba(23, 31, 51, 0.78); -} - -.widget-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 14px; -} - -.fox-widget { - position: relative; - min-height: 350px; - border: 1px solid rgba(255, 255, 255, 0.18); - border-radius: var(--radius); - padding: 22px; - overflow: hidden; - transform: translateY(18px); - opacity: 0; - transition: transform 520ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 520ms ease; -} - -.widget-hot { - background: #9a2148; -} - -.widget-calm { - background: #176d76; -} - -.widget-alert { - background: #5d348d; -} - -.widget-score { - display: inline-flex; - align-items: center; - gap: 10px; - color: rgba(255, 255, 255, 0.86); - font-size: 52px; - line-height: 0.95; - font-weight: 950; - min-height: 58px; -} - -.widget-score img { - width: 42px; - height: 42px; - object-fit: contain; - flex: 0 0 42px; - filter: drop-shadow(0 5px 10px rgba(0, 0, 0, 0.18)); -} - -.fox-widget p { - margin: 10px 0 0; - color: rgba(255, 255, 255, 0.82); - font-size: 20px; -} - -.fox-widget > img { - position: absolute; - left: 50%; - bottom: -28px; - width: min(92%, 310px); - transform: translateX(-50%); - filter: drop-shadow(0 22px 24px rgba(0, 0, 0, 0.24)); -} - -.kotlin-band { - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - border-top: 1px solid rgba(218, 226, 253, 0.12); -} - -.kotlin-band div { - max-width: 760px; -} - -.site-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - width: min(1180px, calc(100% - 32px)); - margin: 0 auto; - padding: 34px 0 44px; - border-top: 1px solid rgba(218, 226, 253, 0.12); - color: var(--muted); - font-size: 13px; -} - -@media (max-width: 980px) { - .hero-shell, - .demo-stage, - .ledger-band { - grid-template-columns: 1fr; - } - - .hero-shell { - padding-top: 22px; - } - - .demo-stage { - width: 100%; - max-width: 520px; - } - - .demo-controls { - min-height: 300px; - } - - .system-grid, - .widget-grid { - grid-template-columns: 1fr; - } - - .system-card { - min-height: 210px; - } - - .nav-links { - display: none; - } -} - -@media (max-width: 620px) { - .site-header { - width: min(1180px, calc(100% - 22px)); - } - - .hero-shell, - .section-band, - .widgets-band, - .kotlin-band, - .ledger-band, - .site-footer { - width: min(360px, calc(100vw - 22px)); - max-width: min(360px, calc(100vw - 22px)); - } - - .header-cta { - display: none; - } - - .hero-copy h1 { - font-size: clamp(54px, 22vw, 82px); - } - - .hero-copy, - .hero-lede, - .signal-row { - max-width: 100%; - } - - .hero-actions { - display: grid; - grid-template-columns: 1fr; - } - - .primary-action, - .secondary-action { - width: 100%; - } - - .phone-shell { - width: min(360px, calc(100vw - 22px)); - max-width: min(360px, calc(100vw - 22px)); - min-height: 590px; - border-radius: 24px; - padding: 10px; - } - - .phone-screen { - min-height: 544px; - padding: 18px 14px 14px; - } - - .screen-top h2 { - font-size: 24px; - } - - .screen-fox { - position: absolute; - top: 18px; - right: 12px; - width: 72px; - height: 72px; - flex: 0 0 72px; - } - - .screen-top { - position: relative; - padding-right: 78px; - } - - .metric-value { - font-size: 44px; - min-width: 54px; - } - - .mini-grid, - .body-map, - .grade-showcase { - grid-template-columns: 1fr; - } - - .kotlin-band, - .site-footer { - flex-direction: column; - align-items: flex-start; - } -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - scroll-behavior: auto !important; - transition-duration: 0.01ms !important; - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - } -} diff --git a/tools/process_forgefox_assets.py b/tools/process_forgefox_assets.py new file mode 100644 index 0000000..b5198f0 --- /dev/null +++ b/tools/process_forgefox_assets.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +""" +Process Forge Fox mascot images into Android-ready transparent PNG assets. + +Inputs are read from Z:\\KOTLIN\\Mascot by default. Outputs are written into this +Kotlin project without modifying the original source images. +""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import sys +from dataclasses import dataclass +from difflib import SequenceMatcher +from pathlib import Path +from typing import Iterable + +import numpy as np +from PIL import Image, ImageDraw, ImageFont, ImageFilter + +try: + from rembg import remove as rembg_remove +except Exception: # pragma: no cover - handled at runtime for local tooling + rembg_remove = None + + +SUPPORTED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"} +MASTER_SIZE = 2048 +RUNTIME_SIZE = 1024 +FALLBACK_SIZE = 512 +TARGET_MAX_DIMENSION = 1750 +RUNTIME_MIN_MARGIN = 32 + + +@dataclass(frozen=True) +class ExpressionSpec: + index: int + slug: str + display_name: str + category: str + source_aliases: tuple[str, ...] + + @property + def resource_id(self) -> str: + return f"forgefox_{self.index:02d}_{self.slug}" + + @property + def filename(self) -> str: + return f"{self.resource_id}.png" + + @property + def enum_name(self) -> str: + return "".join(part.capitalize() for part in self.slug.split("_")) + + +EXPRESSIONS: tuple[ExpressionSpec, ...] = ( + ExpressionSpec(1, "neutral", "Neutral", "core", ("neutral idle", "neutral", "idle")), + ExpressionSpec(2, "smile", "Smile", "core", ("smile",)), + ExpressionSpec(3, "big_happy", "Big Happy", "emotion", ("big happy", "big and happy", "happy")), + ExpressionSpec(4, "excited", "Excited", "emotion", ("excited",)), + ExpressionSpec(5, "laughing", "Laughing", "emotion", ("laughing", "laugh")), + ExpressionSpec(6, "wink_teasing", "Wink / Teasing", "emotion", ("wink teasing", "wink", "teasing")), + ExpressionSpec(7, "determined", "Determined", "fitness", ("determined",)), + ExpressionSpec(8, "serious", "Serious", "fitness", ("serious",)), + ExpressionSpec(9, "angry_coach", "Angry Coach", "warning", ("angry coach", "angry")), + ExpressionSpec(10, "disappointed", "Disappointed", "warning", ("disappointed",)), + ExpressionSpec(11, "sad", "Sad", "emotion", ("sad",)), + ExpressionSpec(12, "exhausted", "Exhausted", "recovery", ("exhausted",)), + ExpressionSpec(13, "sleepy", "Sleepy", "recovery", ("sleepy",)), + ExpressionSpec(14, "shocked", "Shocked", "warning", ("shocked",)), + ExpressionSpec(15, "confused", "Confused", "warning", ("confused",)), + ExpressionSpec(16, "proud", "Proud", "reward", ("proud",)), + ExpressionSpec(17, "flexing", "Flexing", "fitness", ("flexing", "flex")), + ExpressionSpec(18, "fist_pump", "Fist Pump", "reward", ("fist pump", "fist")), + ExpressionSpec(19, "pointing_forward", "Pointing Forward", "widget", ("pointing forward", "pointing")), + ExpressionSpec(20, "clipboard", "Clipboard", "widget", ("holding clipboard todays plan", "holding clipboard", "clipboard", "today plan")), + ExpressionSpec(21, "water_bottle", "Water Bottle", "widget", ("holding water bottle", "water bottle", "bottle")), + ExpressionSpec(22, "dumbbell", "Dumbbell", "fitness", ("lifting dumbbell", "dumbbell")), + ExpressionSpec(23, "pull_up", "Pull Up", "fitness", ("pullup", "pull up")), + ExpressionSpec(24, "tired_towel", "Tired Towel", "recovery", ("sitting tired with towel", "tired towel", "towel")), + ExpressionSpec(25, "trophy_medal", "Trophy Medal", "reward", ("pr trophy medal", "trophy medal", "medal", "trophy")), + ExpressionSpec(26, "streak_fire", "Streak Fire", "reward", ("streak fire pose", "streak fire", "fire")), + ExpressionSpec(27, "repair_streak", "Repair Streak", "recovery", ("repair streak injured or worn out pose", "repair streak", "injured", "worn out")), + ExpressionSpec(28, "rest_blanket", "Rest Blanket", "recovery", ("rest day under the blanket", "rest blanket", "blanket")), + ExpressionSpec(29, "checking_watch", "Checking Watch", "warning", ("late reminder checking watch", "checking watch", "watch")), + ExpressionSpec(30, "coach_arms_crossed", "Coach Arms Crossed", "widget", ("coach mode arms crossed", "arms crossed", "coach")), + ExpressionSpec(31, "supportive_failure", "Supportive Failure", "recovery", ("failure missed target but supportive", "supportive failure", "failure")), + ExpressionSpec(32, "level_up", "Level Up", "reward", ("transformation level up glowing pose", "level up", "transformation")), +) + + +SOURCE_OVERRIDES = { + "forgefox_01_neutral.png": "Neutral Idle.png", + # The provided folder does not include a standalone Sad.png. This closest + # single-expression source avoids synthesizing or altering the mascot. + "forgefox_11_sad.png": "Disappointed.png", +} + + +def normalized_name(value: str) -> str: + value = value.lower() + value = value.replace("’", "").replace("'", "") + value = re.sub(r"[^a-z0-9]+", " ", value) + return re.sub(r"\s+", " ", value).strip() + + +def android_valid_name(name: str) -> bool: + return re.fullmatch(r"[a-z][a-z0-9_]*", name) is not None + + +def source_images(source_dir: Path) -> list[Path]: + return sorted(p for p in source_dir.iterdir() if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS) + + +def score_match(source_stem: str, aliases: Iterable[str]) -> float: + stem = normalized_name(source_stem) + scores: list[float] = [] + for alias in aliases: + alias_norm = normalized_name(alias) + if stem == alias_norm: + scores.append(1.0) + elif alias_norm and alias_norm in stem: + scores.append(0.96) + elif stem and stem in alias_norm: + scores.append(0.92) + else: + scores.append(SequenceMatcher(None, stem, alias_norm).ratio()) + return max(scores) if scores else 0.0 + + +def build_mapping(files: list[Path], mapping_review_path: Path) -> dict[ExpressionSpec, Path]: + remaining = set(files) + mapping: dict[ExpressionSpec, Path] = {} + review: list[dict[str, object]] = [] + + for spec in EXPRESSIONS: + override_name = SOURCE_OVERRIDES.get(spec.filename) + if override_name: + override_path = next((p for p in files if p.name == override_name), None) + if override_path is None: + review.append( + { + "target": spec.filename, + "issue": "missing_override_source", + "expected": override_name, + } + ) + continue + mapping[spec] = override_path + remaining.discard(override_path) + continue + + candidates = sorted( + ((score_match(path.stem, spec.source_aliases), path) for path in remaining), + key=lambda item: item[0], + reverse=True, + ) + if not candidates: + review.append({"target": spec.filename, "issue": "missing_source", "candidates": []}) + continue + best_score, best_path = candidates[0] + second_score = candidates[1][0] if len(candidates) > 1 else 0.0 + if best_score < 0.70 or (best_score - second_score) < 0.04: + review.append( + { + "target": spec.filename, + "issue": "uncertain_match", + "best": best_path.name, + "bestScore": round(best_score, 3), + "candidates": [ + {"file": path.name, "score": round(score, 3)} + for score, path in candidates[:5] + ], + } + ) + continue + mapping[spec] = best_path + remaining.remove(best_path) + + if review or remaining: + mapping_review_path.parent.mkdir(parents=True, exist_ok=True) + mapping_review_path.write_text( + json.dumps( + { + "warnings": review, + "unusedSources": [p.name for p in sorted(remaining)], + "mapped": {spec.filename: path.name for spec, path in mapping.items()}, + }, + indent=2, + ), + encoding="utf-8", + ) + if review: + print(f"WARNING: uncertain source mapping. Review {mapping_review_path}", file=sys.stderr) + return mapping + + +def remove_background(image: Image.Image) -> Image.Image: + rgba = image.convert("RGBA") + if rembg_remove is not None: + try: + return rembg_remove(rgba).convert("RGBA") + except Exception as exc: + print(f"WARNING: rembg failed, using dark-background fallback: {exc}", file=sys.stderr) + return dark_background_fallback(rgba) + + +def dark_background_fallback(image: Image.Image) -> Image.Image: + arr = np.asarray(image.convert("RGBA")).astype(np.float32) + rgb = arr[:, :, :3] + brightness = rgb.mean(axis=2) + max_channel = rgb.max(axis=2) + saturation = max_channel - rgb.min(axis=2) + alpha = np.where( + (brightness < 28) & (saturation < 22), + 0, + np.where(brightness < 54, np.clip((brightness - 28) / 26, 0, 1) * 255, 255), + ) + arr[:, :, 3] = np.minimum(arr[:, :, 3], alpha) + return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA") + + +def refine_alpha_and_decontaminate(image: Image.Image) -> Image.Image: + rgba = image.convert("RGBA") + alpha = rgba.getchannel("A") + alpha = alpha.filter(ImageFilter.MedianFilter(size=3)) + alpha = alpha.filter(ImageFilter.GaussianBlur(radius=0.35)) + + arr = np.asarray(rgba).astype(np.float32) + a = np.asarray(alpha).astype(np.float32) + rgb = arr[:, :, :3] + + # Edge decontamination: lighten black-contaminated semi-transparent pixels + # using nearby opaque mascot color. This reduces black halos without changing + # the mascot's solid dark outfit/shoes. + semi = (a > 4) & (a < 230) + dark_edge = semi & (rgb.mean(axis=2) < 70) + if np.any(dark_edge): + opaque = Image.fromarray(np.where(a[:, :, None] > 220, rgb, 0).astype(np.uint8), "RGB") + expanded = opaque.filter(ImageFilter.GaussianBlur(radius=2.2)) + expanded_arr = np.asarray(expanded).astype(np.float32) + rgb[dark_edge] = np.maximum(rgb[dark_edge], expanded_arr[dark_edge] * 0.65) + + arr[:, :, :3] = rgb + arr[:, :, 3] = a + refined = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA") + return refined + + +def alpha_bounds(image: Image.Image, threshold: int = 8) -> tuple[int, int, int, int] | None: + alpha = np.asarray(image.getchannel("A")) + ys, xs = np.where(alpha > threshold) + if len(xs) == 0 or len(ys) == 0: + return None + return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1 + + +def compose_square(image: Image.Image, canvas_size: int) -> Image.Image: + bounds = alpha_bounds(image) + if bounds is None: + raise ValueError("empty alpha mask after background removal") + trimmed = image.crop(bounds) + width, height = trimmed.size + scale = min(TARGET_MAX_DIMENSION / max(width, height), (canvas_size - 220) / width, (canvas_size - 220) / height) + target = (max(1, int(round(width * scale))), max(1, int(round(height * scale)))) + resized = trimmed.resize(target, Image.Resampling.LANCZOS) + canvas = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0)) + x = (canvas_size - target[0]) // 2 + y = (canvas_size - target[1]) // 2 + canvas.alpha_composite(resized, (x, y)) + return canvas + + +def save_png(image: Image.Image, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + image.save(path, "PNG", optimize=True) + + +def checkerboard(size: tuple[int, int], cell: int = 24) -> Image.Image: + width, height = size + img = Image.new("RGB", size, (236, 236, 236)) + draw = ImageDraw.Draw(img) + for y in range(0, height, cell): + for x in range(0, width, cell): + if ((x // cell) + (y // cell)) % 2: + draw.rectangle((x, y, x + cell - 1, y + cell - 1), fill=(205, 205, 205)) + return img + + +def generate_contact_sheet(fallback_dir: Path, output_path: Path) -> None: + cols, rows = 4, 8 + tile, label_h, pad = 300, 44, 24 + sheet_w = cols * tile + (cols + 1) * pad + sheet_h = rows * (tile + label_h) + (rows + 1) * pad + sheet = checkerboard((sheet_w, sheet_h), cell=20).convert("RGBA") + draw = ImageDraw.Draw(sheet) + try: + font = ImageFont.truetype("arial.ttf", 18) + except Exception: + font = ImageFont.load_default() + + for i, spec in enumerate(EXPRESSIONS): + row, col = divmod(i, cols) + x = pad + col * (tile + pad) + y = pad + row * (tile + label_h + pad) + img = Image.open(fallback_dir / spec.filename).convert("RGBA") + preview = img.resize((tile, tile), Image.Resampling.LANCZOS) + sheet.alpha_composite(preview, (x, y)) + label = spec.resource_id + draw.rectangle((x, y + tile, x + tile, y + tile + label_h), fill=(20, 20, 20, 210)) + draw.text((x + 8, y + tile + 10), label, fill=(255, 255, 255, 255), font=font) + + output_path.parent.mkdir(parents=True, exist_ok=True) + sheet.convert("RGBA").save(output_path, "PNG", optimize=True) + + +def generate_manifest(source_dir: Path, manifest_path: Path) -> None: + manifest = { + "version": 1, + "character": "Forge Fox", + "app": "IRONLOG", + "assetType": "mascot_expression_set", + "background": "transparent", + "runtimeDrawableSize": RUNTIME_SIZE, + "masterSize": MASTER_SIZE, + "fallbackSize": FALLBACK_SIZE, + "sourceFolder": str(source_dir), + "expressions": [ + { + "index": spec.index, + "id": spec.resource_id, + "name": spec.display_name, + "category": spec.category, + "runtimeDrawable": f"R.drawable.{spec.resource_id}", + "master2048": f"app/src/main/assets/forgefox/transparent/2048/{spec.filename}", + "fallback512": f"app/src/main/assets/forgefox/transparent/512/{spec.filename}", + } + for spec in EXPRESSIONS + ], + } + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + +def generate_kotlin_enum(path: Path) -> None: + constants = [] + for spec in EXPRESSIONS: + suffix = "," if spec.index < len(EXPRESSIONS) else ";" + constants.append( + f""" {spec.enum_name}( + id = "{spec.resource_id}", + displayName = "{spec.display_name}", + category = "{spec.category}", + drawableRes = R.drawable.{spec.resource_id} + ){suffix}""" + ) + content = """package com.ironlog.assets + +import androidx.annotation.DrawableRes +import com.ironlog.app.R + +enum class ForgeFoxExpression( + val id: String, + val displayName: String, + val category: String, + @DrawableRes val drawableRes: Int +) { +%s + + companion object { + fun fromId(id: String): ForgeFoxExpression = + entries.firstOrNull { it.id == id } ?: Neutral + } +} +""" % ("\n\n".join(constants)) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def verify_outputs(project_dir: Path, manifest_path: Path) -> list[str]: + errors: list[str] = [] + runtime_dir = project_dir / "app/src/main/res/drawable-nodpi" + master_dir = project_dir / "app/src/main/assets/forgefox/transparent/2048" + fallback_dir = project_dir / "app/src/main/assets/forgefox/transparent/512" + expected = [ + (runtime_dir, RUNTIME_SIZE), + (master_dir, MASTER_SIZE), + (fallback_dir, FALLBACK_SIZE), + ] + for spec in EXPRESSIONS: + if not android_valid_name(spec.resource_id): + errors.append(f"Invalid Android resource name: {spec.resource_id}") + for directory, size in expected: + path = directory / spec.filename + if not path.exists(): + errors.append(f"Missing {path}") + continue + img = Image.open(path).convert("RGBA") + if img.size != (size, size): + errors.append(f"Wrong size {path}: {img.size}, expected {(size, size)}") + alpha = np.asarray(img.getchannel("A")) + if alpha.max() == 0: + errors.append(f"Empty alpha mask: {path}") + if alpha.min() == 255: + errors.append(f"No transparency: {path}") + bounds = alpha_bounds(img) + if bounds is None: + errors.append(f"No visible pixels: {path}") + elif size == RUNTIME_SIZE: + left, top, right, bottom = bounds + margin = min(left, top, size - right, size - bottom) + if margin < RUNTIME_MIN_MARGIN: + errors.append(f"Runtime margin too small ({margin}px): {path}") + if not manifest_path.exists(): + errors.append(f"Missing manifest: {manifest_path}") + else: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if len(manifest.get("expressions", [])) != len(EXPRESSIONS): + errors.append("Manifest does not contain exactly 32 expressions") + return errors + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source", default=r"Z:\KOTLIN\Mascot", help="Source mascot image folder") + parser.add_argument("--project", default=str(Path(__file__).resolve().parents[1]), help="UnifiedPort project folder") + parser.add_argument("--allow-mapping-warnings", action="store_true", help="Continue if only unused source warnings exist") + args = parser.parse_args() + + source_dir = Path(args.source) + project_dir = Path(args.project) + runtime_dir = project_dir / "app/src/main/res/drawable-nodpi" + master_dir = project_dir / "app/src/main/assets/forgefox/transparent/2048" + fallback_dir = project_dir / "app/src/main/assets/forgefox/transparent/512" + assets_dir = project_dir / "app/src/main/assets/forgefox" + mapping_review_path = assets_dir / "source_mapping.json" + manifest_path = assets_dir / "forgefox_manifest.json" + contact_sheet_path = assets_dir / "forgefox_contact_sheet.png" + enum_path = project_dir / "app/src/main/java/com/ironlog/assets/ForgeFoxAssets.kt" + + if not source_dir.exists(): + raise FileNotFoundError(f"Source folder not found: {source_dir}") + + files = source_images(source_dir) + mapping = build_mapping(files, mapping_review_path) + missing = [spec.filename for spec in EXPRESSIONS if spec not in mapping] + if missing: + print("Failed to map required expressions:", file=sys.stderr) + for item in missing: + print(f" - {item}", file=sys.stderr) + return 2 + + processed = 0 + failed: list[str] = [] + for spec in EXPRESSIONS: + source_path = mapping[spec] + try: + source = Image.open(source_path) + transparent = remove_background(source) + transparent = refine_alpha_and_decontaminate(transparent) + master = compose_square(transparent, MASTER_SIZE) + runtime = master.resize((RUNTIME_SIZE, RUNTIME_SIZE), Image.Resampling.LANCZOS) + fallback = master.resize((FALLBACK_SIZE, FALLBACK_SIZE), Image.Resampling.LANCZOS) + save_png(master, master_dir / spec.filename) + save_png(runtime, runtime_dir / spec.filename) + save_png(fallback, fallback_dir / spec.filename) + processed += 1 + except Exception as exc: + failed.append(f"{spec.filename}: {exc}") + print(f"FAILED {spec.filename}: {exc}", file=sys.stderr) + + generate_contact_sheet(fallback_dir, contact_sheet_path) + generate_manifest(source_dir, manifest_path) + generate_kotlin_enum(enum_path) + + errors = verify_outputs(project_dir, manifest_path) + if errors: + print("Quality check failed:", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 3 + + print() + print("Forge Fox asset processing complete.") + print() + print("Source folder:") + print(str(source_dir)) + print() + print("Processed:") + print(processed) + print() + print("Failed:") + print(len(failed)) + print() + print("Runtime drawable output:") + print("app/src/main/res/drawable-nodpi/") + print() + print("Master transparent output:") + print("app/src/main/assets/forgefox/transparent/2048/") + print() + print("Fallback transparent output:") + print("app/src/main/assets/forgefox/transparent/512/") + print() + print("Contact sheet:") + print("app/src/main/assets/forgefox/forgefox_contact_sheet.png") + print() + print("Manifest:") + print("app/src/main/assets/forgefox/forgefox_manifest.json") + print() + print("Kotlin enum:") + print("app/src/main/java/com/ironlog/assets/ForgeFoxAssets.kt") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())