Fix backward-bending joints in the five legacy motions

Abdominal's pinned hands used elbow -40 as the IK plane hint, drawing
the arms hyperextended (user-reported). Flipping the hint bends the
elbows the natural way while the hands stay on the handles. Same class
of fix for the milder cases: Arm Curl and Shoulder Press elbows and
Calfs knees clamp to -8, Side Plank's raised arm to the -70 ROM cap.
The whole library now passes render.py --strict with zero warnings,
making it a valid verification gate. Fixtures regenerated; 48 tests
green.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
2026-07-07 13:06:18 -04:00
parent 7c241ec44c
commit bb234c198f
22 changed files with 172 additions and 53 deletions
+2
View File
@@ -1,5 +1,7 @@
**July 2026**
The Abdominal figure's arms no longer bend backward at the elbow, and a few other form guides lose their subtly hyperextended joints.
The exercise library more than doubles — deadlifts, squats, lunges, bench presses, dumbbell work, pull-ups, and the cable stations — each with its own animated form guide.
Machine form guides now slowly circle around the figure too — seats, cables, and pads turn with the body, showing every exercise from all sides.
+4 -4
View File
@@ -18,8 +18,8 @@
"root": {"pos": [128, 126], "pitch": -4},
"spine": [0, 4],
"neck": 8,
"shoulder_r": 78, "elbow_r": -40,
"shoulder_l": 78, "elbow_l": -40,
"shoulder_r": 78, "elbow_r": 40,
"shoulder_l": 78, "elbow_l": 40,
"hip_r": 66, "knee_r": 70,
"hip_l": 66, "knee_l": 70,
"pins": {"hand_r": [155, 62], "hand_l": [161, 64], "foot_r": [168, 150], "foot_l": [172, 150]}
@@ -29,8 +29,8 @@
"root": {"pos": [128, 126], "pitch": 10},
"spine": [0, 40],
"neck": 18, "head": -26,
"shoulder_r": 128, "elbow_r": -40,
"shoulder_l": 128, "elbow_l": -40,
"shoulder_r": 128, "elbow_r": 40,
"shoulder_l": 128, "elbow_l": 40,
"hip_r": 80, "knee_r": 70,
"hip_l": 80, "knee_l": 70,
"pins": {"hand_r": [190, 76], "hand_l": [196, 78], "foot_r": [168, 150], "foot_l": [172, 150]}
+3 -3
View File
@@ -5,13 +5,13 @@
<path d="M 136.0 52.0 L 164.0 70.0 L 164.0 70.0 L 136.0 52.0 Z" fill="#c5cad4" stroke="#c5cad4" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 164.0 70.0 L 164.0 128.0" stroke="#c5cad4" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="leg_l" d="M 135.9 124.8 L 179.4 110.0 L 180.0 149.4 L 191.0 149.2" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_l" d="M 140.3 40.5 L 164.9 57.4 L 180.8 82.5" stroke="#86cfc5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_l" d="M 140.3 40.5 L 164.9 57.4 L 186.9 77.6" stroke="#86cfc5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="girdle" d="M 140.3 40.5 L 134.4 40.4 L 134.4 42.3" stroke="#3a3f4b" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="pelvisBar" d="M 135.9 124.8 L 130.0 124.0 L 130.0 125.2" stroke="#3a3f4b" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="spine" d="M 130.0 124.0 Q 130.8 81.1 134.4 40.4" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path id="leg_r" d="M 130.0 125.2 L 173.8 111.5 L 176.0 150.8 L 187.0 150.2" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_r" d="M 134.4 42.3 L 159.0 59.3 L 174.9 84.3" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 177.9 89.4 L 177.9 77.4" stroke="#6b7180" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_r" d="M 134.4 42.3 L 159.0 59.3 L 180.9 79.4" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 183.9 84.5 L 183.9 72.5" stroke="#6b7180" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<circle id="head" cx="137.2" cy="20.9" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
<line id="nose" x1="146.7" y1="24.0" x2="153.4" y2="26.1" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+2 -2
View File
@@ -16,8 +16,8 @@
"root": {"pos": [130, 124], "pitch": 2},
"spine": [0, 2],
"neck": 4, "head": 10,
"shoulder_r": 59, "elbow_r": -23,
"shoulder_l": 59, "elbow_l": -23,
"shoulder_r": 59, "elbow_r": -8,
"shoulder_l": 59, "elbow_l": -8,
"hip_r": 84, "knee_r": 64,
"hip_l": 84, "knee_l": 64,
"pins": {"foot_r": [176, 150], "foot_l": [180, 151]}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 136 KiB

+2 -2
View File
@@ -33,8 +33,8 @@
"neck": 16,
"shoulder_r": 44, "elbow_r": 25,
"shoulder_l": 44, "elbow_l": 25,
"hip_r": 66, "knee_r": -18, "ankle_r": -18,
"hip_l": 66, "knee_l": -18, "ankle_l": -18,
"hip_r": 66, "knee_r": -8, "ankle_r": -18,
"hip_l": 66, "knee_l": -8, "ankle_l": -18,
"pins": {"hand_r": [166, 74], "hand_l": [170, 76], "foot_r": [212, 139], "foot_l": [216, 141]}
}
]
@@ -6,13 +6,13 @@
<path d="M 126.0 143.0 L 126.0 151.0" stroke="#c5cad4" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 110.0 151.0 L 142.0 151.0 L 142.0 151.0 L 110.0 151.0 Z" fill="#c5cad4" stroke="#c5cad4" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path id="leg_l" d="M 125.9 130.8 L 167.4 111.1 L 168.0 150.5 L 179.0 150.3" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_l" d="M 101.0 50.1 L 118.7 26.2 L 142.9 8.9" stroke="#86cfc5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_l" d="M 101.0 50.1 L 118.7 26.2 L 139.5 5.0" stroke="#86cfc5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="girdle" d="M 101.0 50.1 L 95.1 50.1 L 95.1 52.0" stroke="#3a3f4b" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="pelvisBar" d="M 125.9 130.8 L 120.0 130.0 L 120.0 131.2" stroke="#3a3f4b" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="spine" d="M 120.0 130.0 Q 103.0 90.4 95.1 50.1" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path id="leg_r" d="M 120.0 131.2 L 161.9 112.6 L 164.0 151.9 L 175.0 151.3" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_r" d="M 95.1 52.0 L 112.8 28.1 L 137.0 10.7" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 130.0 9.8 L 150.0 9.8" stroke="#6b7180" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_r" d="M 95.1 52.0 L 112.8 28.1 L 133.6 6.8" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 126.6 5.9 L 146.6 5.9" stroke="#6b7180" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<circle id="head" cx="98.6" cy="30.7" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
<line id="nose" x1="108.5" y1="29.3" x2="115.4" y2="28.3" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

+2 -2
View File
@@ -28,8 +28,8 @@
"root": {"pos": [120, 130], "pitch": -20},
"spine": [0, 6],
"neck": 24, "head": -18,
"shoulder_r": 130, "elbow_r": -18,
"shoulder_l": 130, "elbow_l": -18,
"shoulder_r": 130, "elbow_r": -8,
"shoulder_l": 130, "elbow_l": -8,
"hip_r": 45, "knee_r": 47,
"hip_l": 45, "knee_l": 47,
"pins": {"foot_r": [164, 151], "foot_l": [168, 152]}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 148 KiB

+3 -3
View File
@@ -6,13 +6,13 @@
<path d="M 126.0 143.0 L 126.0 151.0" stroke="#c5cad4" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 110.0 151.0 L 142.0 151.0 L 142.0 151.0 L 110.0 151.0 Z" fill="#c5cad4" stroke="#c5cad4" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path id="leg_l" d="M 125.9 130.8 L 167.4 111.1 L 168.0 150.5 L 179.0 150.3" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_l" d="M 101.0 50.1 L 118.7 26.2 L 142.9 8.9" stroke="#86cfc5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_l" d="M 101.0 50.1 L 118.7 26.2 L 139.5 5.0" stroke="#86cfc5" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="girdle" d="M 101.0 50.1 L 95.1 50.1 L 95.1 52.0" stroke="#3a3f4b" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="pelvisBar" d="M 125.9 130.8 L 120.0 130.0 L 120.0 131.2" stroke="#3a3f4b" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="spine" d="M 120.0 130.0 Q 103.0 90.4 95.1 50.1" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path id="leg_r" d="M 120.0 131.2 L 161.9 112.6 L 164.0 151.9 L 175.0 151.3" stroke="#3a3f4b" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_r" d="M 95.1 52.0 L 112.8 28.1 L 137.0 10.7" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 130.0 9.8 L 150.0 9.8" stroke="#6b7180" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_r" d="M 95.1 52.0 L 112.8 28.1 L 133.6 6.8" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 126.6 5.9 L 146.6 5.9" stroke="#6b7180" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<circle id="head" cx="98.6" cy="30.7" r="10" fill="white" stroke="#3a3f4b" stroke-width="6"/>
<line id="nose" x1="108.5" y1="29.3" x2="115.4" y2="28.3" stroke="#3a3f4b" stroke-width="4" stroke-linecap="round"/>
<g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="#6b7180">

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -2,7 +2,7 @@
<title>Side Plank</title>
<path d="M 48.0 160.9 L 277.9 160.9 L 277.9 150.5 L 48.0 150.5 Z" fill="none" stroke="#b9bec9" stroke-width="3" stroke-linejoin="round"/>
<path id="leg_r" d="M 179.9 137.8 L 225.2 145.6 L 264.6 152.5 L 262.7 163.1" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_r" d="M 96.2 122.5 L 101.4 93.4 L 106.6 64.3" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_r" d="M 96.2 122.5 L 111.2 96.9 L 126.2 71.4" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="girdle" d="M 90.3 124.4 L 90.3 122.5 L 96.2 122.5" stroke="#0d9488" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="pelvisBar" d="M 174.0 138.2 L 174.0 137.0 L 179.9 137.8" stroke="#0d9488" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="spine" d="M 174.0 137.0 Q 131.2 129.6 90.3 122.5" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+1 -1
View File
@@ -19,7 +19,7 @@
"root": {"pos": [174, 137], "yaw": 180, "pitch": 80},
"spine": [0, 0],
"neck": 0, "head": -80,
"shoulder_r": -90, "elbow_r": 0,
"shoulder_r": -70, "elbow_r": 0,
"shoulder_l": 80, "elbow_l": 90,
"hip_r": 0, "knee_r": 0,
"hip_l": 0, "knee_l": 0,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 131 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
<title>Side Plank</title>
<path d="M 48.0 160.9 L 277.9 160.9 L 277.9 150.5 L 48.0 150.5 Z" fill="none" stroke="#b9bec9" stroke-width="3" stroke-linejoin="round"/>
<path id="leg_r" d="M 179.9 137.8 L 225.2 145.6 L 264.6 152.5 L 262.7 163.1" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_r" d="M 96.2 122.5 L 101.4 93.4 L 106.6 64.3" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="arm_r" d="M 96.2 122.5 L 111.2 96.9 L 126.2 71.4" stroke="#a9afba" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="girdle" d="M 90.3 124.4 L 90.3 122.5 L 96.2 122.5" stroke="#0d9488" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="pelvisBar" d="M 174.0 138.2 L 174.0 137.0 L 179.9 137.8" stroke="#0d9488" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="spine" d="M 174.0 137.0 Q 131.2 129.6 90.3 122.5" stroke="#0d9488" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -18,8 +18,8 @@
"root": {"pos": [128, 126], "pitch": -4},
"spine": [0, 4],
"neck": 8,
"shoulder_r": 78, "elbow_r": -40,
"shoulder_l": 78, "elbow_l": -40,
"shoulder_r": 78, "elbow_r": 40,
"shoulder_l": 78, "elbow_l": 40,
"hip_r": 66, "knee_r": 70,
"hip_l": 66, "knee_l": 70,
"pins": {"hand_r": [155, 62], "hand_l": [161, 64], "foot_r": [168, 150], "foot_l": [172, 150]}
@@ -29,8 +29,8 @@
"root": {"pos": [128, 126], "pitch": 10},
"spine": [0, 40],
"neck": 18, "head": -26,
"shoulder_r": 128, "elbow_r": -40,
"shoulder_l": 128, "elbow_l": -40,
"shoulder_r": 128, "elbow_r": 40,
"shoulder_l": 128, "elbow_l": 40,
"hip_r": 80, "knee_r": 70,
"hip_l": 80, "knee_l": 70,
"pins": {"hand_r": [190, 76], "hand_l": [196, 78], "foot_r": [168, 150], "foot_l": [172, 150]}
@@ -16,8 +16,8 @@
"root": {"pos": [130, 124], "pitch": 2},
"spine": [0, 2],
"neck": 4, "head": 10,
"shoulder_r": 59, "elbow_r": -23,
"shoulder_l": 59, "elbow_l": -23,
"shoulder_r": 59, "elbow_r": -8,
"shoulder_l": 59, "elbow_l": -8,
"hip_r": 84, "knee_r": 64,
"hip_l": 84, "knee_l": 64,
"pins": {"foot_r": [176, 150], "foot_l": [180, 151]}
@@ -33,8 +33,8 @@
"neck": 16,
"shoulder_r": 44, "elbow_r": 25,
"shoulder_l": 44, "elbow_l": 25,
"hip_r": 66, "knee_r": -18, "ankle_r": -18,
"hip_l": 66, "knee_l": -18, "ankle_l": -18,
"hip_r": 66, "knee_r": -8, "ankle_r": -18,
"hip_l": 66, "knee_l": -8, "ankle_l": -18,
"pins": {"hand_r": [166, 74], "hand_l": [170, 76], "foot_r": [212, 139], "foot_l": [216, 141]}
}
]
@@ -28,8 +28,8 @@
"root": {"pos": [120, 130], "pitch": -20},
"spine": [0, 6],
"neck": 24, "head": -18,
"shoulder_r": 130, "elbow_r": -18,
"shoulder_l": 130, "elbow_l": -18,
"shoulder_r": 130, "elbow_r": -8,
"shoulder_l": 130, "elbow_l": -8,
"hip_r": 45, "knee_r": 47,
"hip_l": 45, "knee_l": 47,
"pins": {"foot_r": [164, 151], "foot_l": [168, 152]}
@@ -19,7 +19,7 @@
"root": {"pos": [174, 137], "yaw": 180, "pitch": 80},
"spine": [0, 0],
"neck": 0, "head": -80,
"shoulder_r": -90, "elbow_r": 0,
"shoulder_r": -70, "elbow_r": 0,
"shoulder_l": 80, "elbow_l": 90,
"hip_r": 0, "knee_r": 0,
"hip_l": 0, "knee_l": 0,
@@ -18,7 +18,7 @@ struct ExerciseLibraryView: View {
ExerciseLibraryDetailView(exerciseName: name)
}
}
.navigationTitle("Exercises")
.navigationTitle("\(ExerciseMotionLibrary.exerciseNames.count) Exercises")
}
}
@@ -27,12 +27,21 @@ struct ExerciseLibraryView: View {
/// reference content (summary, targets, setup/execution/cues), and the weight
/// progression chart; the looping form-guide figure holds the bottom.
struct ExerciseLibraryDetailView: View {
@Environment(SyncEngine.self) private var sync
let exerciseName: String
/// This exercise wherever it appears in the user's splits the source of the
/// recorded machine settings.
@Query private var exercises: [Exercise]
/// Presents the settings editor; carries the splits the edit writes back to.
@State private var settingsEdit: SettingsEditRoute?
/// True while content extends below the scroll area drives the bottom fade
/// that makes the reference copy read as scrollable.
@State private var moreBelow = false
/// One completed weighted log is enough to justify the progression chart;
/// bodyweight and timed exercises never accrue one, so they skip the chart
/// instead of plotting a flat zero line.
@@ -53,18 +62,33 @@ struct ExerciseLibraryDetailView: View {
}
/// Distinct recorded machine-settings lists for this exercise. Usually one; when
/// splits disagree, each distinct list keeps its split's name as a label.
private var settingsGroups: [(label: String?, settings: [MachineSetting])] {
var groups: [(label: String?, settings: [MachineSetting])] = []
/// splits disagree, each distinct list keeps its split's name as a label. Each
/// group carries every split showing that list, so an edit updates them all and
/// they stay deduped.
private var settingsGroups: [SettingsGroup] {
var groups: [SettingsGroup] = []
for exercise in exercises {
guard let settings = exercise.machineSettings, !settings.isEmpty else { continue }
guard !groups.contains(where: { $0.settings == settings }) else { continue }
groups.append((exercise.split?.name, settings))
if let i = groups.firstIndex(where: { $0.settings == settings }) {
if let id = exercise.split?.id { groups[i].splitIDs.append(id) }
continue
}
groups.append(SettingsGroup(
label: exercise.split?.name,
settings: settings,
splitIDs: exercise.split.map { [$0.id] } ?? []
))
}
if groups.count == 1 { groups[0].label = nil }
return groups
}
/// Every split containing this exercise the write targets when settings are
/// recorded here for the first time.
private var containingSplitIDs: [String] {
exercises.compactMap { $0.split?.id }
}
var body: some View {
VStack(spacing: 0) {
ScrollView {
@@ -73,9 +97,17 @@ struct ExerciseLibraryDetailView: View {
// exercise the authored info identifies as machine-based, as an
// empty state, so the feature is discoverable before first use.
if !settingsGroups.isEmpty {
MachineSettingsCard(groups: settingsGroups)
MachineSettingsCard(groups: settingsGroups, onEdit: edit(group:))
} else if info?.isMachineBased == true {
MachineSettingsCard(groups: [])
MachineSettingsCard(
groups: [],
onEdit: edit(group:),
// Recording needs a split exercise to write to without
// one the card explains instead of offering a dead button.
onAdd: containingSplitIDs.isEmpty ? nil : {
settingsEdit = SettingsEditRoute(initial: [], targetSplitIDs: containingSplitIDs)
}
)
}
if let info {
ExerciseInfoContent(info: info)
@@ -88,36 +120,105 @@ struct ExerciseLibraryDetailView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Fade the last lines while more content lies below, so the reference
// copy visibly continues past the figure boundary; gone at scroll end.
.onScrollGeometryChange(for: Bool.self) { geo in
geo.contentOffset.y + geo.containerSize.height < geo.contentSize.height - 12
} action: { _, more in
withAnimation(.easeInOut(duration: 0.2)) { moreBelow = more }
}
.overlay(alignment: .bottom) {
if moreBelow {
LinearGradient(
colors: [Color(.systemBackground).opacity(0), Color(.systemBackground)],
startPoint: .top, endPoint: .bottom
)
.frame(height: 28)
.allowsHitTesting(false)
.transition(.opacity)
}
}
ExerciseFigureSlot(exerciseName: exerciseName)
}
.navigationTitle(exerciseName)
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $settingsEdit) { route in
MachineSettingsSheet(exerciseName: exerciseName, initialSettings: route.initial) { updated in
let targets = route.targetSplitIDs
Task {
for splitID in targets {
await sync.writeBackMachineSettings(updated, exerciseName: exerciseName, splitID: splitID)
}
}
}
}
}
private func edit(group: SettingsGroup) {
settingsEdit = SettingsEditRoute(initial: group.settings, targetSplitIDs: group.splitIDs)
}
}
/// One distinct recorded settings list and the splits it came from (the edit targets).
fileprivate struct SettingsGroup {
var label: String?
var settings: [MachineSetting]
var splitIDs: [String]
}
/// Sheet route: the settings to seed the editor with and the splits a save writes to.
private struct SettingsEditRoute: Identifiable {
let id = UUID()
var initial: [MachineSetting]
var targetSplitIDs: [String]
}
/// The user's recorded machine comfort settings, set apart from the reference text
/// as a card: namevalue rows, grouped per split when the recorded lists differ.
/// Empty `groups` renders the not-yet-recorded state (shown for machine-based
/// exercises so the feature is visible before first use).
/// exercises so the feature is visible before first use), with an Add button when
/// the exercise lives in at least one split (`onAdd` non-nil). A single group edits
/// from the header; disagreeing groups edit per group.
private struct MachineSettingsCard: View {
let groups: [(label: String?, settings: [MachineSetting])]
let groups: [SettingsGroup]
var onEdit: (SettingsGroup) -> Void
var onAdd: (() -> Void)? = nil
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Your Machine Settings")
.font(.headline)
HStack {
Text("Your Machine Settings")
.font(.headline)
Spacer()
if groups.count == 1 {
Button("Edit") { onEdit(groups[0]) }
.font(.subheadline)
} else if groups.isEmpty, let onAdd {
Button("Add") { onAdd() }
.font(.subheadline)
}
}
if groups.isEmpty {
Text("None recorded yet. Mark the exercise as machine-based in its editor, or use the settings button on the workout screen.")
Text(onAdd != nil
? "None recorded yet — save the seat, pad, and pin positions once and they'll be here next time."
: "None recorded yet. Add this exercise to a split, then record its settings here or from the workout screen.")
.font(.callout)
.foregroundStyle(.secondary)
}
ForEach(Array(groups.enumerated()), id: \.offset) { _, group in
VStack(alignment: .leading, spacing: 4) {
if let label = group.label {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if groups.count > 1 {
Button("Edit") { onEdit(group) }
.font(.caption)
}
}
}
ForEach(Array(group.settings.enumerated()), id: \.offset) { _, setting in
HStack(alignment: .firstTextBaseline) {
@@ -79,9 +79,12 @@ struct WorkoutLogListView: View {
Section {
LabeledContent("Started", value: doc.start.formattedRelativeDateTime())
LabeledContent("Status") {
// Emphasize the states that mean the workout amounted to
// something a running or finished one over notStarted.
let emphasized = statusEnum == .inProgress || statusEnum == .completed
Text(statusEnum.displayName)
.fontWeight(statusEnum == .inProgress ? .bold : nil)
.foregroundStyle(statusEnum == .inProgress ? Color.accentColor : Color.secondary)
.fontWeight(emphasized ? .bold : nil)
.foregroundStyle(emphasized ? Color.accentColor : Color.secondary)
}
}
@@ -401,6 +404,17 @@ private struct WorkoutLogRow: View {
private var showsWeight: Bool { loadType == .weight }
/// Compact glanceable summary of the recorded machine settings ("Seat 4 · Back 3")
/// so the values are readable while setting up the machine, without opening the
/// sheet. Nil when nothing is recorded yet the button then reads "Settings".
private var settingsSummary: String? {
guard let settings = log.machineSettings, !settings.isEmpty else { return nil }
let parts = settings
.map { "\($0.name) \($0.value)".trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
var body: some View {
HStack(alignment: .top) {
Button {
@@ -443,9 +457,11 @@ private struct WorkoutLogRow: View {
if log.machineSettings != nil
|| ExerciseInfoLibrary.info(for: log.exerciseName)?.isMachineBased == true {
Button(action: onSettingsTap) {
Label("Settings", systemImage: "slider.horizontal.3")
Label(settingsSummary ?? "Settings", systemImage: "slider.horizontal.3")
.font(.footnote.weight(.medium))
.foregroundStyle(Color.accentColor)
.multilineTextAlignment(.trailing)
.lineLimit(2)
}
.buttonStyle(.plain)
}
File diff suppressed because one or more lines are too long