Add the exercise reference library, animated exercise figures, and exercise categories

Exercise Library/ holds per-exercise reference docs (setup, cues,
mistakes, progressions) with SVG visuals and a Python-rendered motion
pipeline; Workouts/ExerciseFigure renders the bundled *.motion.json
rigs as animated stick figures on the exercise screen. Exercises gain
a warm-up/main-circuit category, timed exercises display hold time via
planSummary, and a completed exercise reopens to a check screen instead
of its timers.
This commit is contained in:
2026-07-06 01:15:52 -04:00
parent ddd5631ef2
commit 7274f155e9
79 changed files with 2521 additions and 11 deletions
@@ -22,8 +22,9 @@ import UIKit
/// reaches a **Finish** page offering **One More** (append a bonus set) and **Done**
/// (which also auto-fires after a configurable countdown, completing the exercise).
///
/// The paged flow occupies the **top half** of the screen; the bottom half is reserved
/// blank for a later iteration. A row of phase dots tracks progress: purple for work,
/// The paged flow occupies the **top half** of the screen; the bottom half shows the
/// animated form-guide figure when the exercise has a bundled motion
/// (`ExerciseFigureSlot`). A row of phase dots tracks progress: purple for work,
/// teal for rest, with the current phase drawn as a wider dash.
struct ExerciseProgressView: View {
@Environment(\.dismiss) private var dismiss
@@ -85,6 +86,11 @@ struct ExerciseProgressView: View {
/// the transient TabView snap-to-0, so it isn't reset on open.
@State private var startsResumed: Bool
/// True when the exercise was already completed when this screen opened it shows a
/// static Completed page instead of dropping back into the timer flow. Fixed at init
/// so completing the exercise from inside the flow doesn't swap the page mid-dismiss.
@State private var startsCompleted: Bool
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) {
self._doc = doc
self.logID = logID
@@ -102,6 +108,7 @@ struct ExerciseProgressView: View {
// the TabView's snap-to-0 on first layout.
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
_startsResumed = State(initialValue: !notStarted)
_startsCompleted = State(initialValue: log?.status == WorkoutStatus.completed.rawValue)
let base = 1
// Resume on the first unfinished set's work page (clamped to the last set).
@@ -181,6 +188,16 @@ struct ExerciseProgressView: View {
}
var body: some View {
if startsCompleted {
CompletedPhaseView()
.navigationTitle(log?.exerciseName ?? "")
.navigationBarTitleDisplayMode(.inline)
} else {
flowBody
}
}
private var flowBody: some View {
VStack(spacing: 0) {
// Paged flow top half.
TabView(selection: $currentPage) {
@@ -198,9 +215,9 @@ struct ExerciseProgressView: View {
}
}
// Reserved space for a later iteration (set log, history, chart, ).
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Bottom half: the looping form-guide figure when a bundled motion
// matches this exercise; empty space otherwise.
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
}
.navigationTitle(log?.exerciseName ?? "")
.navigationBarTitleDisplayMode(.inline)
@@ -677,6 +694,22 @@ private extension View {
}
}
// MARK: - Completed Phase
/// Shown instead of the run flow when the exercise was already completed on open.
private struct CompletedPhaseView: View {
var body: some View {
VStack(spacing: 14) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 96))
.foregroundStyle(.green)
Text("Completed")
.font(.system(.title, design: .rounded, weight: .heavy))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Ready Phase
private struct ReadyPhaseView: View {