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:
2026-07-06 18:35:15 -04:00
parent ff1ffbb1f6
commit 669ecf1259
76 changed files with 733 additions and 268 deletions
@@ -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: 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).
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