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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user