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
@@ -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.
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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 |
@@ -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]}
|
||||
|
||||
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 136 KiB |
@@ -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 |
@@ -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]}
|
||||
|
||||
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 148 KiB |
@@ -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 |
@@ -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,
|
||||
|
||||
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 131 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 |
@@ -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: name–value 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)
|
||||
}
|
||||
|
||||