// // 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 { $0.name == name }) let completedRaw = WorkoutStatus.completed.rawValue _weightedLogs = Query(filter: #Predicate { $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 } }