Files
workouts/Workouts/Views/Exercises/ExerciseLibraryView.swift
T
rzen 669ecf1259 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
2026-07-06 18:35:15 -04:00

274 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// ExerciseLibraryView.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
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
/// reference page.
struct ExerciseLibraryView: View {
var body: some View {
List(ExerciseMotionLibrary.exerciseNames, id: \.self) { name in
NavigationLink(name) {
ExerciseLibraryDetailView(exerciseName: name)
}
}
.navigationTitle("Exercises")
}
}
/// 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) {
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)
.navigationBarTitleDisplayMode(.inline)
}
}
/// 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 {
VStack(alignment: .leading, spacing: 16) {
if !info.summary.isEmpty {
Text(info.summary)
.font(.callout)
.foregroundStyle(.secondary)
}
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))
}
}
}
}
}
/// Steps number themselves by their position among the section's steps, so an
/// interleaved paragraph doesn't break the count.
private func ordinal(for item: ExerciseInfo.Item, at index: Int, in section: ExerciseInfo.Section) -> Int? {
guard case .step = item else { return nil }
return section.items[...index].reduce(0) { count, it in
if case .step = it { count + 1 } else { count }
}
}
@ViewBuilder
private func itemRow(_ item: ExerciseInfo.Item, ordinal: Int?) -> some View {
switch item {
case .step(let text):
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("\(ordinal ?? 0).")
.foregroundStyle(.secondary)
.monospacedDigit()
Text(text)
}
.font(.callout)
case .bullet(let text):
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("•")
.foregroundStyle(.secondary)
Text(text)
}
.font(.callout)
case .paragraph(let text):
Text(text)
.font(.callout)
}
}
}
/// The `Targets:` muscles as wrapping capsule chips.
private struct TargetChips: View {
let targets: [String]
var body: some View {
FlowLayout(spacing: 6) {
ForEach(targets, id: \.self) { target in
Text(target)
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.accentColor.opacity(0.12), in: Capsule())
.foregroundStyle(Color.accentColor)
}
}
}
}
/// Minimal leading-aligned wrapping layout for the target chips.
private struct FlowLayout: Layout {
var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = layoutRows(maxWidth: proposal.width ?? .infinity, subviews: subviews)
let height = rows.map(\.height).reduce(0, +) + spacing * CGFloat(max(0, rows.count - 1))
return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var y = bounds.minY
var index = 0
for row in layoutRows(maxWidth: bounds.width, subviews: subviews) {
var x = bounds.minX
for size in row.sizes {
subviews[index].place(
at: CGPoint(x: x, y: y),
proposal: ProposedViewSize(size)
)
x += size.width + spacing
index += 1
}
y += row.height + spacing
}
}
private struct Row {
var sizes: [CGSize] = []
var width: CGFloat = 0
var height: CGFloat = 0
}
private func layoutRows(maxWidth: CGFloat, subviews: Subviews) -> [Row] {
var rows: [Row] = []
var current = Row()
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
let next = current.sizes.isEmpty ? size.width : current.width + spacing + size.width
if next > maxWidth, !current.sizes.isEmpty {
rows.append(current)
current = Row()
}
current.sizes.append(size)
current.width = current.sizes.isEmpty ? 0 : current.width + (current.sizes.count == 1 ? size.width : spacing + size.width)
current.height = max(current.height, size.height)
}
if !current.sizes.isEmpty { rows.append(current) }
return rows
}
}