Surface machine settings in the exercise library and refine machine rigs
Library detail screens now lead with the user's recorded machine settings (per-split when they disagree, empty-state card for machine-based entries) and append the weight progression chart. Starter seeds mark machine exercises with an empty machineSettings list so the settings UI lights up before first use. The figure rig gains a frontal body profile for face-on machines, props that can ride mid joints (knees/elbows), and an alternating four-frame Bird Dog loop. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
This commit is contained in:
@@ -140,16 +140,27 @@ struct ExerciseFigureView: View {
|
||||
|
||||
// MARK: Props
|
||||
|
||||
/// The extremity point a prop follows (plus the lower-bone unit direction for
|
||||
/// perpendicular items). Nil when any referenced limb isn't drawn this frame.
|
||||
/// Prop joint refs → (limb, chain index): extremities are index 2, mid joints
|
||||
/// (elbows/knees) index 1, so equipment can ride either joint. Kept 1:1 with
|
||||
/// the reference renderer's `JOINT_LIMB`.
|
||||
private static let propJoints: [String: (limb: FigureLimb, index: Int)] = [
|
||||
"hand_r": (.armR, 2), "elbow_r": (.armR, 1),
|
||||
"hand_l": (.armL, 2), "elbow_l": (.armL, 1),
|
||||
"foot_r": (.legR, 2), "knee_r": (.legR, 1),
|
||||
"foot_l": (.legL, 2), "knee_l": (.legL, 1),
|
||||
]
|
||||
|
||||
/// The joint point a prop follows (plus the unit direction of the bone ending
|
||||
/// at the first joint, for perpendicular items). Nil when any referenced limb
|
||||
/// isn't drawn this frame.
|
||||
private func jointAnchor(_ geo: FigureGeometry, _ ref: PropJointRef) -> (point: CGPoint, direction: CGVector)? {
|
||||
var points: [CGPoint] = []
|
||||
var direction: CGVector?
|
||||
for name in ref.names {
|
||||
guard let limb = FigureLimb.allCases.first(where: { $0.pinKey == name }),
|
||||
let chain = geo.limbs[limb], chain.count >= 2 else { return nil }
|
||||
let a = chain[chain.count - 2]
|
||||
let b = chain[chain.count - 1]
|
||||
guard let joint = Self.propJoints[name],
|
||||
let chain = geo.limbs[joint.limb], chain.count > joint.index else { return nil }
|
||||
let a = chain[joint.index - 1]
|
||||
let b = chain[joint.index]
|
||||
points.append(b)
|
||||
if direction == nil {
|
||||
let d = max(hypot(b.x - a.x, b.y - a.y), 1)
|
||||
|
||||
@@ -22,6 +22,13 @@ struct ExerciseInfo {
|
||||
var targets: [String]
|
||||
var sections: [Section]
|
||||
|
||||
/// Whether the authored `Type:` identifies this as a machine exercise — the
|
||||
/// library convention is that every machine entry's type starts "Machine-based".
|
||||
/// Drives the machine-settings sections' visibility before any settings exist.
|
||||
var isMachineBased: Bool {
|
||||
type?.localizedCaseInsensitiveContains("machine") == true
|
||||
}
|
||||
|
||||
struct Section {
|
||||
var title: String
|
||||
var items: [Item]
|
||||
|
||||
@@ -35,6 +35,9 @@ struct ExerciseMotion: Codable {
|
||||
let name: String
|
||||
/// 1-based frame used for the static visual (unused by the animated renderer).
|
||||
let primary: Int?
|
||||
/// Body-profile override (e.g. `"frontal"`: foreshortened legs for face-on
|
||||
/// seated machines). Nil uses the `neutral` profile.
|
||||
let figure: String?
|
||||
/// Parts (`arm_r`, `leg_l`, `spine`, …) drawn in the working accent color.
|
||||
let working: [String]?
|
||||
/// Limbs fully occluded in this view — never drawn.
|
||||
@@ -45,8 +48,9 @@ struct ExerciseMotion: Codable {
|
||||
let frames: [MotionKeyFrame]
|
||||
}
|
||||
|
||||
/// A prop's joint reference: one extremity (`"hand_r"`) or the midpoint of two
|
||||
/// (`["foot_r", "foot_l"]`). Extremity keys match the pin keys.
|
||||
/// A prop's joint reference: one joint (`"hand_r"`, `"knee_l"`, `"elbow_r"`, …)
|
||||
/// or the midpoint of two (`["foot_r", "foot_l"]`, `["knee_r", "foot_r"]`).
|
||||
/// Extremity keys match the pin keys; elbows/knees are the mid joints.
|
||||
enum PropJointRef: Codable {
|
||||
case single(String)
|
||||
case midpoint([String])
|
||||
@@ -180,7 +184,7 @@ enum ExerciseMotionLibrary {
|
||||
let bodyData = try? Data(contentsOf: bodyURL),
|
||||
let motion = try? JSONDecoder().decode(ExerciseMotion.self, from: motionData),
|
||||
let profiles = try? JSONDecoder().decode([String: ExerciseBodyProfile].self, from: bodyData),
|
||||
let body = profiles["neutral"]
|
||||
let body = profiles[motion.figure ?? "neutral"]
|
||||
else { return nil }
|
||||
return Resources(motion: motion, body: body)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "Abductor",
|
||||
"primary": 2,
|
||||
"figure": "frontal",
|
||||
"working": ["leg_r", "leg_l"],
|
||||
"hide": [],
|
||||
"props": [
|
||||
{"type": "scene", "shapes": [
|
||||
{"kind": "rect", "x": 152, "y": 56, "w": 16, "h": 72, "r": 5},
|
||||
{"kind": "rect", "x": 130, "y": 126, "w": 60, "h": 10, "r": 3},
|
||||
{"kind": "line", "pts": [[136, 78], [136, 108]], "w": 4, "color": "prop"},
|
||||
{"kind": "line", "pts": [[188, 78], [188, 108]], "w": 4, "color": "prop"},
|
||||
{"kind": "line", "pts": [[213, 110], [213, 132]], "w": 9, "color": "prop"},
|
||||
{"kind": "line", "pts": [[107, 110], [107, 132]], "w": 9, "color": "prop"}
|
||||
]}
|
||||
{"kind": "line", "pts": [[160, 136], [160, 150]], "w": 6}
|
||||
]},
|
||||
{"type": "pad", "at": ["knee_r"], "angle": 90, "halfLen": 13, "w": 6},
|
||||
{"type": "pad", "at": ["knee_l"], "angle": 90, "halfLen": 13, "w": 6}
|
||||
],
|
||||
"frames": [
|
||||
{
|
||||
@@ -19,16 +19,14 @@
|
||||
"root": [160, 116],
|
||||
"spine": [90, 90], "neck": 90,
|
||||
"arm_r": [-48, -82], "arm_l": [-132, -98],
|
||||
"leg_r": [-22, 0], "leg_l": [-158, 0],
|
||||
"pins": {"foot_r": [180, 156], "foot_l": [140, 156]}
|
||||
"leg_r": [-75, -90], "leg_l": [-105, -90]
|
||||
},
|
||||
{
|
||||
"hold": 0.5, "tween": 1.1,
|
||||
"root": [160, 116],
|
||||
"spine": [90, 90], "neck": 90,
|
||||
"arm_r": [-48, -82], "arm_l": [-132, -98],
|
||||
"leg_r": [-22, 0], "leg_l": [-158, 0],
|
||||
"pins": {"foot_r": [216, 156], "foot_l": [104, 156]}
|
||||
"leg_r": [-30, -90], "leg_l": [-150, -90]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "Adductor",
|
||||
"primary": 2,
|
||||
"figure": "frontal",
|
||||
"working": ["leg_r", "leg_l"],
|
||||
"hide": [],
|
||||
"props": [
|
||||
{"type": "scene", "shapes": [
|
||||
{"kind": "rect", "x": 152, "y": 56, "w": 16, "h": 72, "r": 5},
|
||||
{"kind": "rect", "x": 130, "y": 126, "w": 60, "h": 10, "r": 3},
|
||||
{"kind": "line", "pts": [[136, 78], [136, 108]], "w": 4, "color": "prop"},
|
||||
{"kind": "line", "pts": [[188, 78], [188, 108]], "w": 4, "color": "prop"},
|
||||
{"kind": "line", "pts": [[197, 110], [197, 132]], "w": 9, "color": "prop"},
|
||||
{"kind": "line", "pts": [[123, 110], [123, 132]], "w": 9, "color": "prop"}
|
||||
]}
|
||||
{"kind": "line", "pts": [[160, 136], [160, 150]], "w": 6}
|
||||
]},
|
||||
{"type": "pad", "at": ["knee_r"], "angle": 90, "halfLen": 13, "w": 6},
|
||||
{"type": "pad", "at": ["knee_l"], "angle": 90, "halfLen": 13, "w": 6}
|
||||
],
|
||||
"frames": [
|
||||
{
|
||||
@@ -19,16 +19,14 @@
|
||||
"root": [160, 116],
|
||||
"spine": [90, 90], "neck": 90,
|
||||
"arm_r": [-48, -82], "arm_l": [-132, -98],
|
||||
"leg_r": [-22, 0], "leg_l": [-158, 0],
|
||||
"pins": {"foot_r": [216, 156], "foot_l": [104, 156]}
|
||||
"leg_r": [-30, -90], "leg_l": [-150, -90]
|
||||
},
|
||||
{
|
||||
"hold": 0.5, "tween": 1.1,
|
||||
"root": [160, 116],
|
||||
"spine": [90, 90], "neck": 90,
|
||||
"arm_r": [-48, -82], "arm_l": [-132, -98],
|
||||
"leg_r": [-22, 0], "leg_l": [-158, 0],
|
||||
"pins": {"foot_r": [180, 156], "foot_l": [140, 156]}
|
||||
"leg_r": [-75, -90], "leg_l": [-105, -90]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"props": [
|
||||
{"type": "scene", "shapes": [
|
||||
{"kind": "line", "pts": [[116, 134], [150, 134]], "w": 8},
|
||||
{"kind": "line", "pts": [[146, 42], [176, 68]], "w": 8},
|
||||
{"kind": "line", "pts": [[176, 68], [176, 128]], "w": 5}
|
||||
{"kind": "line", "pts": [[136, 52], [164, 70]], "w": 8},
|
||||
{"kind": "line", "pts": [[164, 70], [164, 128]], "w": 5}
|
||||
]},
|
||||
{"type": "bar", "at": ["hand_r", "hand_l"], "angle": 90, "halfLen": 6}
|
||||
],
|
||||
@@ -16,7 +16,7 @@
|
||||
"hold": 0.4, "tween": 1.0,
|
||||
"root": [130, 124],
|
||||
"spine": [88, 86], "neck": 82, "gaze": -18,
|
||||
"arm_r": [-35, -52], "arm_l": [-35, -52],
|
||||
"arm_r": [-35, -58], "arm_l": [-35, -58],
|
||||
"leg_r": [-8, -72], "leg_l": [-8, -72],
|
||||
"pins": {"foot_r": [176, 150], "foot_l": [180, 151]}
|
||||
},
|
||||
@@ -24,7 +24,7 @@
|
||||
"hold": 0.4, "tween": 1.2,
|
||||
"root": [130, 124],
|
||||
"spine": [88, 86], "neck": 82, "gaze": -18,
|
||||
"arm_r": [-35, 138], "arm_l": [-35, 138],
|
||||
"arm_r": [-35, 50], "arm_l": [-35, 50],
|
||||
"leg_r": [-8, -72], "leg_l": [-8, -72],
|
||||
"pins": {"foot_r": [176, 150], "foot_l": [180, 151]}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Bird Dog",
|
||||
"primary": 2,
|
||||
"working": ["arm_r", "leg_l"],
|
||||
"working": ["arm_r", "leg_l", "arm_l", "leg_r"],
|
||||
"frames": [
|
||||
{
|
||||
"hold": 0.5, "tween": 0.8,
|
||||
@@ -18,6 +18,22 @@
|
||||
"arm_r": [161, 161], "arm_l": [-90, -90],
|
||||
"leg_r": [-83, 0], "leg_l": [15, 15],
|
||||
"pins": {"hand_l": [111, 154]}
|
||||
},
|
||||
{
|
||||
"hold": 0.5, "tween": 0.8,
|
||||
"root": [190, 106],
|
||||
"spine": [171, 171], "neck": 187, "gaze": 205,
|
||||
"arm_r": [-90, -90], "arm_l": [-90, -90],
|
||||
"leg_r": [-83, 0], "leg_l": [-83, 0],
|
||||
"pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
|
||||
},
|
||||
{
|
||||
"hold": 1.4, "tween": 0.8,
|
||||
"root": [190, 106],
|
||||
"spine": [171, 171], "neck": 187, "gaze": 195,
|
||||
"arm_r": [-90, -90], "arm_l": [161, 161],
|
||||
"leg_r": [15, 15], "leg_l": [-83, 0],
|
||||
"pins": {"hand_r": [105, 152]}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
{"kind": "line", "pts": [[138, 128], [138, 150]], "w": 5},
|
||||
{"kind": "line", "pts": [[120, 150], [156, 150]], "w": 5},
|
||||
{"kind": "rect", "x": 198, "y": 147, "w": 34, "h": 6, "r": 1},
|
||||
{"kind": "line", "pts": [[183, 62], [183, 124]], "w": 4},
|
||||
{"kind": "rect", "x": 170, "y": 121, "w": 27, "h": 7, "r": 2},
|
||||
{"kind": "line", "pts": [[183, 62], [183, 122]], "w": 4},
|
||||
{"kind": "line", "pts": [[164, 76], [183, 76]], "w": 3, "color": "prop"}
|
||||
]},
|
||||
{"type": "pad", "at": ["knee_r", "knee_l"], "angle": 0, "halfLen": 14, "w": 7},
|
||||
{"type": "pad", "at": ["foot_r", "foot_l"], "angle": 15, "halfLen": 7, "w": 5}
|
||||
],
|
||||
"frames": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Dead Bug",
|
||||
"primary": 2,
|
||||
"working": ["arm_r", "leg_l"],
|
||||
"working": ["arm_r", "leg_l", "arm_l", "leg_r"],
|
||||
"frames": [
|
||||
{
|
||||
"hold": 0.5, "tween": 0.8,
|
||||
@@ -16,6 +16,20 @@
|
||||
"spine": [180, 180], "neck": 168, "gaze": 90,
|
||||
"arm_r": [135, 135], "arm_l": [90, 90],
|
||||
"leg_r": [64, -10], "leg_l": [16, 16]
|
||||
},
|
||||
{
|
||||
"hold": 0.5, "tween": 0.8,
|
||||
"root": [185, 148],
|
||||
"spine": [180, 180], "neck": 168, "gaze": 90,
|
||||
"arm_r": [90, 90], "arm_l": [90, 90],
|
||||
"leg_r": [64, -10], "leg_l": [64, -10]
|
||||
},
|
||||
{
|
||||
"hold": 0.9, "tween": 0.8,
|
||||
"root": [185, 148],
|
||||
"spine": [180, 180], "neck": 168, "gaze": 90,
|
||||
"arm_r": [90, 90], "arm_l": [135, 135],
|
||||
"leg_r": [16, 16], "leg_l": [64, -10]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,30 +6,29 @@
|
||||
"props": [
|
||||
{"type": "scene", "shapes": [
|
||||
{"kind": "line", "pts": [[126, 130], [162, 130]], "w": 8},
|
||||
{"kind": "line", "pts": [[124, 130], [124, 56]], "w": 5},
|
||||
{"kind": "line", "pts": [[124, 56], [198, 12]], "w": 5},
|
||||
{"kind": "rect", "x": 170, "y": 112, "w": 40, "h": 7, "r": 3}
|
||||
{"kind": "line", "pts": [[144, 132], [144, 150]], "w": 5},
|
||||
{"kind": "circle", "c": [272, 19], "r": 4, "fill": true, "color": "prop"}
|
||||
]},
|
||||
{"type": "cable", "from": [198, 8], "to": ["hand_r", "hand_l"]},
|
||||
{"type": "bar", "at": ["hand_r", "hand_l"], "halfLen": 28}
|
||||
{"type": "cable", "from": [272, 19], "to": ["hand_r", "hand_l"], "w": 4},
|
||||
{"type": "bar", "at": ["hand_r", "hand_l"], "halfLen": 24}
|
||||
],
|
||||
"frames": [
|
||||
{
|
||||
"hold": 0.4, "tween": 1.0,
|
||||
"root": [150, 122],
|
||||
"spine": [93, 90], "neck": 85, "gaze": 55,
|
||||
"arm_r": [20, 10], "arm_l": [20, 10],
|
||||
"arm_r": [35, 35], "arm_l": [35, 35],
|
||||
"leg_r": [-12, -80], "leg_l": [-12, -80],
|
||||
"pins": {"hand_r": [198, 24], "hand_l": [202, 26],
|
||||
"pins": {"hand_r": [194, 1], "hand_l": [198, 3],
|
||||
"foot_r": [193, 150], "foot_l": [197, 151]}
|
||||
},
|
||||
{
|
||||
"hold": 0.4, "tween": 1.2,
|
||||
"root": [150, 122],
|
||||
"spine": [93, 90], "neck": 85, "gaze": 30,
|
||||
"arm_r": [-115, -20], "arm_l": [-115, -20],
|
||||
"arm_r": [-38, 38], "arm_l": [-38, 38],
|
||||
"leg_r": [-12, -80], "leg_l": [-12, -80],
|
||||
"pins": {"hand_r": [165, 66], "hand_l": [169, 68],
|
||||
"pins": {"hand_r": [194, 35], "hand_l": [198, 37],
|
||||
"foot_r": [193, 150], "foot_l": [197, 151]}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"hold": 0.4, "tween": 1.0,
|
||||
"root": [140, 122],
|
||||
"spine": [85, 82], "neck": 80, "gaze": 5,
|
||||
"arm_r": [-10, -25], "arm_l": [-10, -25],
|
||||
"arm_r": [-40, -8], "arm_l": [-40, -8],
|
||||
"leg_r": [-8, -75], "leg_l": [-8, -75],
|
||||
"pins": {"hand_r": [200, 62], "hand_l": [204, 64],
|
||||
"foot_r": [188, 148], "foot_l": [192, 149]}
|
||||
|
||||
@@ -17,17 +17,19 @@
|
||||
"hold": 0.4, "tween": 1.1,
|
||||
"root": [118, 124],
|
||||
"spine": [100, 98], "neck": 86, "gaze": 6,
|
||||
"arm_r": [-78, 30], "arm_l": [-78, 30],
|
||||
"arm_r": [-108, 12], "arm_l": [-108, 12],
|
||||
"leg_r": [-24, -74], "leg_l": [-24, -74],
|
||||
"pins": {"foot_r": [156, 151], "foot_l": [160, 152]}
|
||||
"pins": {"hand_r": [124, 62], "hand_l": [128, 64],
|
||||
"foot_r": [156, 151], "foot_l": [160, 152]}
|
||||
},
|
||||
{
|
||||
"hold": 0.4, "tween": 1.1,
|
||||
"root": [118, 124],
|
||||
"spine": [100, 98], "neck": 86, "gaze": 6,
|
||||
"arm_r": [-78, -88], "arm_l": [-78, -88],
|
||||
"arm_r": [-81, -61], "arm_l": [-81, -61],
|
||||
"leg_r": [-24, -74], "leg_l": [-24, -74],
|
||||
"pins": {"foot_r": [156, 151], "foot_l": [160, 152]}
|
||||
"pins": {"hand_r": [124, 96], "hand_l": [128, 98],
|
||||
"foot_r": [156, 151], "foot_l": [160, 152]}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
"shin": 40,
|
||||
"leftOffset": [6, 2]
|
||||
},
|
||||
"frontal": {
|
||||
"headR": 10,
|
||||
"neck": 20,
|
||||
"spine1": 43,
|
||||
"spine2": 42,
|
||||
"upperArm": 30,
|
||||
"foreArm": 30,
|
||||
"thigh": 22,
|
||||
"shin": 26,
|
||||
"leftOffset": [6, 2]
|
||||
},
|
||||
"female": {
|
||||
"headR": 9,
|
||||
"neck": 19,
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00C4K0NY1NRCFPB3XY",
|
||||
"loadType" : 1,
|
||||
"machineSettings" : [
|
||||
|
||||
],
|
||||
"name" : "Abdominal",
|
||||
"order" : 0,
|
||||
"reps" : 10,
|
||||
@@ -17,6 +20,9 @@
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT001AY3SXTEYHA94MHZ",
|
||||
"loadType" : 1,
|
||||
"machineSettings" : [
|
||||
|
||||
],
|
||||
"name" : "Rotary",
|
||||
"order" : 1,
|
||||
"reps" : 10,
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00GV3336R9C25W4X2A",
|
||||
"loadType" : 1,
|
||||
"machineSettings" : [
|
||||
|
||||
],
|
||||
"name" : "Leg Press",
|
||||
"order" : 0,
|
||||
"reps" : 10,
|
||||
@@ -17,6 +20,9 @@
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00Z28DJ7PSP7Q11CJH",
|
||||
"loadType" : 1,
|
||||
"machineSettings" : [
|
||||
|
||||
],
|
||||
"name" : "Leg Curl",
|
||||
"order" : 1,
|
||||
"reps" : 10,
|
||||
@@ -27,6 +33,9 @@
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00D53Q4QMWAE1BHXR6",
|
||||
"loadType" : 1,
|
||||
"machineSettings" : [
|
||||
|
||||
],
|
||||
"name" : "Leg Extension",
|
||||
"order" : 2,
|
||||
"reps" : 10,
|
||||
@@ -37,6 +46,9 @@
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT006HW2KWNA5BCCDCJE",
|
||||
"loadType" : 1,
|
||||
"machineSettings" : [
|
||||
|
||||
],
|
||||
"name" : "Abductor",
|
||||
"order" : 3,
|
||||
"reps" : 10,
|
||||
@@ -47,6 +59,9 @@
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00H5B5YD16868Y95ZQ",
|
||||
"loadType" : 1,
|
||||
"machineSettings" : [
|
||||
|
||||
],
|
||||
"name" : "Adductor",
|
||||
"order" : 4,
|
||||
"reps" : 10,
|
||||
@@ -57,6 +72,9 @@
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT002S88DB6NKYZS4EAW",
|
||||
"loadType" : 1,
|
||||
"machineSettings" : [
|
||||
|
||||
],
|
||||
"name" : "Calfs",
|
||||
"order" : 5,
|
||||
"reps" : 10,
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00N1B90PA7K5ABWSSP",
|
||||
"loadType" : 1,
|
||||
"machineSettings" : [
|
||||
|
||||
],
|
||||
"name" : "Lat Pull Down",
|
||||
"order" : 0,
|
||||
"reps" : 10,
|
||||
@@ -17,6 +20,9 @@
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00R81QRWGCCQG933FZ",
|
||||
"loadType" : 1,
|
||||
"machineSettings" : [
|
||||
|
||||
],
|
||||
"name" : "Seated Row",
|
||||
"order" : 1,
|
||||
"reps" : 10,
|
||||
@@ -27,6 +33,9 @@
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT000FX4241G5MPG4WG2",
|
||||
"loadType" : 1,
|
||||
"machineSettings" : [
|
||||
|
||||
],
|
||||
"name" : "Shoulder Press",
|
||||
"order" : 2,
|
||||
"reps" : 10,
|
||||
@@ -37,6 +46,9 @@
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT00AD5TSVJPZZVA2S41",
|
||||
"loadType" : 1,
|
||||
"machineSettings" : [
|
||||
|
||||
],
|
||||
"name" : "Chest Press",
|
||||
"order" : 3,
|
||||
"reps" : 10,
|
||||
@@ -47,6 +59,9 @@
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT006QZ6XT95301HKJT8",
|
||||
"loadType" : 1,
|
||||
"machineSettings" : [
|
||||
|
||||
],
|
||||
"name" : "Tricep Press",
|
||||
"order" : 4,
|
||||
"reps" : 10,
|
||||
@@ -57,6 +72,9 @@
|
||||
"durationSeconds" : 0,
|
||||
"id" : "01DXF6DT009J5H4Z0XPHBT1H0K",
|
||||
"loadType" : 1,
|
||||
"machineSettings" : [
|
||||
|
||||
],
|
||||
"name" : "Arm Curl",
|
||||
"order" : 5,
|
||||
"reps" : 10,
|
||||
|
||||
@@ -311,6 +311,24 @@ final class SyncEngine {
|
||||
do { try context.save() } catch { report("Cache save failed", error) }
|
||||
}
|
||||
|
||||
/// Push edited machine settings onto the originating split's exercise (matched
|
||||
/// by name — logs reference exercises by name only), following the seed
|
||||
/// clone-on-edit redirect so the live clone is updated. Skips silently when the
|
||||
/// split is gone or no exercise matches, and when nothing changed (so a pristine
|
||||
/// seed isn't needlessly forked). Never caches the split's id across the save,
|
||||
/// since saving a seed mints a new one.
|
||||
func writeBackMachineSettings(_ settings: [MachineSetting], exerciseName: String, splitID: String?) async {
|
||||
guard let splitID else { return }
|
||||
let liveID = currentSplitID(for: splitID)
|
||||
guard let split = CacheMapper.fetchSplit(id: liveID, in: context) else { return }
|
||||
var splitDoc = SplitDocument(from: split)
|
||||
guard let idx = splitDoc.exercises.firstIndex(where: { $0.name == exerciseName }) else { return }
|
||||
guard splitDoc.exercises[idx].machineSettings != settings else { return }
|
||||
splitDoc.exercises[idx].machineSettings = settings
|
||||
splitDoc.updatedAt = Date()
|
||||
await save(split: splitDoc)
|
||||
}
|
||||
|
||||
func save(workout doc: WorkoutDocument) async {
|
||||
guard let store else { return }
|
||||
do {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// The Exercises library screen, pushed from Settings → Library: every exercise with
|
||||
/// a bundled motion rig (the same catalog the picker offers), each opening its
|
||||
@@ -21,22 +22,73 @@ struct ExerciseLibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// One library exercise, in the standard half-and-half exercise layout: the top half
|
||||
/// scrolls the user's machine settings (their setup cheat-sheet), the authored
|
||||
/// reference content (summary, targets, setup/execution/cues…), and the weight
|
||||
/// progression chart; the looping form-guide figure holds the bottom.
|
||||
struct ExerciseLibraryDetailView: View {
|
||||
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]
|
||||
|
||||
/// 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.
|
||||
@Query private var weightedLogs: [WorkoutLog]
|
||||
|
||||
init(exerciseName: String) {
|
||||
self.exerciseName = exerciseName
|
||||
let name = exerciseName
|
||||
_exercises = Query(filter: #Predicate<Exercise> { $0.name == name })
|
||||
let completedRaw = WorkoutStatus.completed.rawValue
|
||||
_weightedLogs = Query(filter: #Predicate<WorkoutLog> {
|
||||
$0.exerciseName == name && $0.statusRaw == completedRaw && $0.weight > 0
|
||||
})
|
||||
}
|
||||
|
||||
private var info: ExerciseInfo? {
|
||||
ExerciseInfoLibrary.info(for: exerciseName)
|
||||
}
|
||||
|
||||
/// 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])] = []
|
||||
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 groups.count == 1 { groups[0].label = nil }
|
||||
return groups
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let info {
|
||||
ExerciseInfoContent(info: info)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Show the card whenever settings are recorded — or, for an
|
||||
// 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)
|
||||
} else if info?.isMachineBased == true {
|
||||
MachineSettingsCard(groups: [])
|
||||
}
|
||||
if let info {
|
||||
ExerciseInfoContent(info: info)
|
||||
}
|
||||
if !weightedLogs.isEmpty {
|
||||
WeightProgressionChartView(exerciseName: exerciseName)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
ExerciseFigureSlot(exerciseName: exerciseName)
|
||||
}
|
||||
.navigationTitle(exerciseName)
|
||||
@@ -44,38 +96,76 @@ struct ExerciseLibraryDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
private struct MachineSettingsCard: View {
|
||||
let groups: [(label: String?, settings: [MachineSetting])]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Your Machine Settings")
|
||||
.font(.headline)
|
||||
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.")
|
||||
.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)
|
||||
}
|
||||
ForEach(Array(group.settings.enumerated()), id: \.offset) { _, setting in
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(setting.name)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(setting.value)
|
||||
.fontWeight(.semibold)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if !info.summary.isEmpty {
|
||||
Text(info.summary)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !info.targets.isEmpty {
|
||||
TargetChips(targets: info.targets)
|
||||
}
|
||||
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))
|
||||
}
|
||||
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
|
||||
|
||||
@@ -12,6 +12,7 @@ import SwiftData
|
||||
import Charts
|
||||
|
||||
struct ExerciseView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(AppServices.self) private var services
|
||||
|
||||
let workout: Workout
|
||||
@@ -23,6 +24,7 @@ struct ExerciseView: View {
|
||||
@State private var doc: WorkoutDocument
|
||||
@State private var showingPlanEdit = false
|
||||
@State private var showingNotesEdit = false
|
||||
@State private var showingMachineSettings = false
|
||||
|
||||
/// `seedDoc` lets the caller hand over an in-memory document (e.g. the parent's
|
||||
/// working copy right after adding an exercise) so the screen doesn't wait on
|
||||
@@ -41,6 +43,9 @@ struct ExerciseView: View {
|
||||
var body: some View {
|
||||
Group {
|
||||
if let log {
|
||||
// Deliberately NOT the half-and-half figure layout: this screen is
|
||||
// dense with the plan, notes, settings, and the progression chart —
|
||||
// the form guide lives in the run flow and the exercise library.
|
||||
content(for: log)
|
||||
} else {
|
||||
// The just-added log hasn't reached the cache yet; refresh shortly.
|
||||
@@ -54,6 +59,16 @@ struct ExerciseView: View {
|
||||
.sheet(isPresented: $showingNotesEdit) {
|
||||
NotesEditView(workout: workout, logID: logID)
|
||||
}
|
||||
.sheet(isPresented: $showingMachineSettings) {
|
||||
if let log {
|
||||
MachineSettingsSheet(
|
||||
exerciseName: log.exerciseName,
|
||||
initialSettings: log.machineSettings ?? []
|
||||
) { updated in
|
||||
applyMachineSettings(updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
refreshDocIfNeeded()
|
||||
// Take over this run: the watch parks and locks it while we're editing here.
|
||||
@@ -94,6 +109,42 @@ struct ExerciseView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Machine Settings — shown when the log carries settings, or for
|
||||
// any exercise the authored library identifies as machine-based, so the
|
||||
// section is discoverable before settings are first recorded.
|
||||
if log.machineSettings != nil
|
||||
|| ExerciseInfoLibrary.info(for: log.exerciseName)?.isMachineBased == true {
|
||||
let settings = log.machineSettings ?? []
|
||||
Section {
|
||||
if settings.isEmpty {
|
||||
Text("No settings recorded")
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
} else {
|
||||
ForEach(Array(settings.enumerated()), id: \.offset) { _, setting in
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(setting.name)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(setting.value)
|
||||
.fontWeight(.semibold)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Machine Settings")
|
||||
Spacer()
|
||||
Button("Edit") {
|
||||
showingMachineSettings = true
|
||||
}
|
||||
.font(.subheadline)
|
||||
.textCase(.none)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notes Section (Read-only with Edit button)
|
||||
Section {
|
||||
if let notes = log.notes, !notes.isEmpty {
|
||||
@@ -116,9 +167,12 @@ struct ExerciseView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Progress Tracking Chart
|
||||
Section(header: Text("Progress Tracking")) {
|
||||
WeightProgressionChartView(exerciseName: log.exerciseName)
|
||||
// MARK: - Progress Tracking Chart (weighted exercises only — a weight
|
||||
// chart is meaningless for bodyweight/timed logs)
|
||||
if LoadType(rawValue: log.loadType) == .weight {
|
||||
Section(header: Text("Progress Tracking")) {
|
||||
WeightProgressionChartView(exerciseName: log.exerciseName)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pull plan/notes edits made in the sheets back into the live doc.
|
||||
@@ -130,6 +184,23 @@ struct ExerciseView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply edited machine settings: update the log's plan-time snapshot (persisted
|
||||
/// with the workout), then write the same settings back to the originating
|
||||
/// split's exercise as the durable default — same sequencing as the workout
|
||||
/// list's machine sheet.
|
||||
private func applyMachineSettings(_ settings: [MachineSetting]) {
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||
doc.logs[i].machineSettings = settings
|
||||
doc.updatedAt = Date()
|
||||
let snapshot = doc
|
||||
let exerciseName = doc.logs[i].exerciseName
|
||||
let splitID = doc.splitID
|
||||
Task {
|
||||
await sync.save(workout: snapshot)
|
||||
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID)
|
||||
}
|
||||
}
|
||||
|
||||
/// If the requested log isn't in the working doc yet (just-added race), pull a
|
||||
/// fresh copy from the cache entity once it catches up.
|
||||
private func refreshDocIfNeeded() {
|
||||
|
||||
@@ -334,28 +334,10 @@ struct WorkoutLogListView: View {
|
||||
let splitID = doc.splitID
|
||||
Task {
|
||||
await sync.save(workout: workoutSnapshot)
|
||||
await writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID)
|
||||
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the settings onto the originating split's exercise (matched by name —
|
||||
/// logs reference exercises by name only), following the seed clone-on-edit
|
||||
/// redirect so the live clone is updated. Skips silently when the split is gone
|
||||
/// or no exercise matches, and when nothing changed (so a pristine seed isn't
|
||||
/// needlessly forked). Never caches the split's id across the save, since saving
|
||||
/// a seed mints a new one.
|
||||
private func writeBackMachineSettings(_ settings: [MachineSetting], exerciseName: String, splitID: String?) async {
|
||||
guard let splitID else { return }
|
||||
let liveID = sync.currentSplitID(for: splitID)
|
||||
guard let split = CacheMapper.fetchSplit(id: liveID, in: modelContext) else { return }
|
||||
var splitDoc = SplitDocument(from: split)
|
||||
guard let idx = splitDoc.exercises.firstIndex(where: { $0.name == exerciseName }) else { return }
|
||||
guard splitDoc.exercises[idx].machineSettings != settings else { return }
|
||||
splitDoc.exercises[idx].machineSettings = settings
|
||||
splitDoc.updatedAt = Date()
|
||||
await sync.save(split: splitDoc)
|
||||
}
|
||||
|
||||
/// Recompute the workout's status/end from its logs, then persist.
|
||||
private func save() {
|
||||
doc.recomputeStatusFromLogs()
|
||||
@@ -549,8 +531,9 @@ struct SplitExercisePickerSheet: View {
|
||||
/// machine-based log (non-nil `machineSettings`), so there's no machine toggle —
|
||||
/// just the ordered rows (reusing `MachineSettingsEditor`) plus Save/Cancel. Save
|
||||
/// hands the edited array back to the caller, which persists it to the log and
|
||||
/// writes it back to the split's exercise as the new default.
|
||||
private struct MachineSettingsSheet: View {
|
||||
/// writes it back to the split's exercise as the new default. Shared with
|
||||
/// `ExerciseView`'s Machine Settings section.
|
||||
struct MachineSettingsSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let exerciseName: String
|
||||
|
||||
Reference in New Issue
Block a user