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
274 lines
10 KiB
Swift
274 lines
10 KiB
Swift
//
|
||
// 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: 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 {
|
||
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
|
||
}
|
||
}
|