Author the machine exercise circuit with props and corrected hip machine motions
Fourteen Planet Fitness machine exercises join the rig library, each with authored motion, info page, and schematic equipment via the new props layer (scene shapes, cables, bars, pads) rendered in lockstep by render.py and the in-app figure renderer. Abductor and Adductor are authored face-on with the real seated machine motion — knees-bent legs swinging apart/together against knee pads — replacing the earlier middle-split depiction. The watch target now bundles the figure renderer and motion rigs too. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
//
|
||||
// ExerciseLibraryView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The Exercises library screen, pushed from Settings → Library: every exercise with
|
||||
/// a bundled motion rig (the same catalog the picker offers), each opening its
|
||||
/// reference page.
|
||||
struct ExerciseLibraryView: View {
|
||||
var body: some View {
|
||||
List(ExerciseMotionLibrary.exerciseNames, id: \.self) { name in
|
||||
NavigationLink(name) {
|
||||
ExerciseLibraryDetailView(exerciseName: name)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Exercises")
|
||||
}
|
||||
}
|
||||
|
||||
/// One library exercise, in the standard half-and-half exercise layout: the authored
|
||||
/// reference content (summary, targets, setup/execution/cues…) scrolls in the top
|
||||
/// half, the looping form-guide figure holds the bottom. Falls back to a full-screen
|
||||
/// figure when no `info.md` is bundled.
|
||||
struct ExerciseLibraryDetailView: View {
|
||||
let exerciseName: String
|
||||
|
||||
private var info: ExerciseInfo? {
|
||||
ExerciseInfoLibrary.info(for: exerciseName)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let info {
|
||||
ExerciseInfoContent(info: info)
|
||||
}
|
||||
ExerciseFigureSlot(exerciseName: exerciseName)
|
||||
}
|
||||
.navigationTitle(exerciseName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a parsed `ExerciseInfo`: summary, target chips, then each authored
|
||||
/// section with its numbered steps or bullets.
|
||||
private struct ExerciseInfoContent: View {
|
||||
let info: ExerciseInfo
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if !info.summary.isEmpty {
|
||||
Text(info.summary)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !info.targets.isEmpty {
|
||||
TargetChips(targets: info.targets)
|
||||
}
|
||||
|
||||
ForEach(info.sections, id: \.title) { section in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(section.title)
|
||||
.font(.headline)
|
||||
ForEach(Array(section.items.enumerated()), id: \.offset) { index, item in
|
||||
itemRow(item, ordinal: ordinal(for: item, at: index, in: section))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
/// Steps number themselves by their position among the section's steps, so an
|
||||
/// interleaved paragraph doesn't break the count.
|
||||
private func ordinal(for item: ExerciseInfo.Item, at index: Int, in section: ExerciseInfo.Section) -> Int? {
|
||||
guard case .step = item else { return nil }
|
||||
return section.items[...index].reduce(0) { count, it in
|
||||
if case .step = it { count + 1 } else { count }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func itemRow(_ item: ExerciseInfo.Item, ordinal: Int?) -> some View {
|
||||
switch item {
|
||||
case .step(let text):
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text("\(ordinal ?? 0).")
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
Text(text)
|
||||
}
|
||||
.font(.callout)
|
||||
case .bullet(let text):
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text("•")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(text)
|
||||
}
|
||||
.font(.callout)
|
||||
case .paragraph(let text):
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Targets:` muscles as wrapping capsule chips.
|
||||
private struct TargetChips: View {
|
||||
let targets: [String]
|
||||
|
||||
var body: some View {
|
||||
FlowLayout(spacing: 6) {
|
||||
ForEach(targets, id: \.self) { target in
|
||||
Text(target)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.accentColor.opacity(0.12), in: Capsule())
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal leading-aligned wrapping layout for the target chips.
|
||||
private struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 6
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let rows = layoutRows(maxWidth: proposal.width ?? .infinity, subviews: subviews)
|
||||
let height = rows.map(\.height).reduce(0, +) + spacing * CGFloat(max(0, rows.count - 1))
|
||||
return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
var y = bounds.minY
|
||||
var index = 0
|
||||
for row in layoutRows(maxWidth: bounds.width, subviews: subviews) {
|
||||
var x = bounds.minX
|
||||
for size in row.sizes {
|
||||
subviews[index].place(
|
||||
at: CGPoint(x: x, y: y),
|
||||
proposal: ProposedViewSize(size)
|
||||
)
|
||||
x += size.width + spacing
|
||||
index += 1
|
||||
}
|
||||
y += row.height + spacing
|
||||
}
|
||||
}
|
||||
|
||||
private struct Row {
|
||||
var sizes: [CGSize] = []
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
}
|
||||
|
||||
private func layoutRows(maxWidth: CGFloat, subviews: Subviews) -> [Row] {
|
||||
var rows: [Row] = []
|
||||
var current = Row()
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
let next = current.sizes.isEmpty ? size.width : current.width + spacing + size.width
|
||||
if next > maxWidth, !current.sizes.isEmpty {
|
||||
rows.append(current)
|
||||
current = Row()
|
||||
}
|
||||
current.sizes.append(size)
|
||||
current.width = current.sizes.isEmpty ? 0 : current.width + (current.sizes.count == 1 ? size.width : spacing + size.width)
|
||||
current.height = max(current.height, size.height)
|
||||
}
|
||||
if !current.sizes.isEmpty { rows.append(current) }
|
||||
return rows
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user