// // ExerciseLibraryView.swift // Workouts // // Copyright 2026 Rouslan Zenetl. All Rights Reserved. // import SwiftUI import SwiftData /// The Exercises segment of the Library tab: every bundled exercise (the same /// catalog the picker offers), grouped into `ExerciseCatalog`'s curated sections and /// searchable by name, category, or target muscle. Tapping a row opens its reference /// page. Pushed from `LibraryView`, so it relies on that view's `NavigationStack`. struct ExerciseLibraryView: View { @State private var searchText = "" private var sections: [ExerciseCatalogSection] { ExerciseCatalog.sections(matching: searchText) } var body: some View { Group { if sections.isEmpty { ContentUnavailableView.search(text: searchText) } else { List { ForEach(sections, id: \.title) { section in Section(section.title) { ForEach(section.names, id: \.self) { name in NavigationLink(name) { ExerciseLibraryDetailView(exerciseName: name) } } } } } } } .navigationTitle("\(ExerciseMotionLibrary.exerciseNames.count) Exercises") .searchable( text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Name, category, or muscle") } } /// 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 { @Environment(SyncEngine.self) private var sync @Environment(AppServices.self) private var services let exerciseName: String /// This exercise wherever it appears in the user's routines — the source of the /// recorded machine settings. @Query private var exercises: [Exercise] /// Presents the settings editor; carries the routines the edit writes back to. @State private var settingsEdit: SettingsEditRoute? /// True while content extends below the scroll area — drives the bottom fade /// that makes the reference copy read as scrollable. @State private var moreBelow = false /// 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 /// routines disagree, each distinct list keeps its routine's name as a label. Each /// group carries every routine showing that list, so an edit updates them all and /// they stay deduped. private var settingsGroups: [SettingsGroup] { var groups: [SettingsGroup] = [] for exercise in exercises { guard let settings = exercise.machineSettings, !settings.isEmpty else { continue } if let i = groups.firstIndex(where: { $0.settings == settings }) { if let id = exercise.routine?.id { groups[i].routineIDs.append(id) } continue } groups.append(SettingsGroup( label: exercise.routine?.name, settings: settings, routineIDs: exercise.routine.map { [$0.id] } ?? [] )) } if groups.count == 1 { groups[0].label = nil } return groups } /// Every routine containing this exercise — the write targets when settings are /// recorded here for the first time. private var containingRoutineIDs: [String] { exercises.compactMap { $0.routine?.id } } 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, onEdit: edit(group:)) } else if info?.isMachineBased == true { MachineSettingsCard( groups: [], onEdit: edit(group:), // Recording needs a routine exercise to write to — without // one the card explains instead of offering a dead button. onAdd: containingRoutineIDs.isEmpty ? nil : { settingsEdit = SettingsEditRoute(initial: [], targetRoutineIDs: containingRoutineIDs) } ) } if let info { ExerciseInfoContent(info: info) } if !weightedLogs.isEmpty { WeightProgressionChartView(exerciseName: exerciseName) } } .padding() .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, maxHeight: .infinity) // Fade the last lines while more content lies below, so the reference // copy visibly continues past the figure boundary; gone at scroll end. .onScrollGeometryChange(for: Bool.self) { geo in geo.contentOffset.y + geo.containerSize.height < geo.contentSize.height - 12 } action: { _, more in withAnimation(.easeInOut(duration: 0.2)) { moreBelow = more } } .overlay(alignment: .bottom) { if moreBelow { LinearGradient( colors: [Color(.systemBackground).opacity(0), Color(.systemBackground)], startPoint: .top, endPoint: .bottom ) .frame(height: 28) .allowsHitTesting(false) .transition(.opacity) } } ExerciseFigureSlot(exerciseName: exerciseName) } .navigationTitle(exerciseName) .navigationBarTitleDisplayMode(.inline) .toolbar { if let info { ToolbarItem(placement: .primaryAction) { let announcer = services.speechAnnouncer Button { if announcer.isSpeaking { announcer.stop() } else { announcer.speak(info.spokenScript(name: exerciseName, brief: false)) } } label: { Image(systemName: announcer.isSpeaking ? "stop.fill" : "speaker.wave.2.fill") } .accessibilityLabel(announcer.isSpeaking ? "Stop reading" : "Read instructions aloud") } } } // Leaving the screen shouldn't keep narrating from a page you can no longer see. .onDisappear { services.speechAnnouncer.stop() } .sheet(item: $settingsEdit) { route in MachineSettingsSheet(exerciseName: exerciseName, initialSettings: route.initial) { updated in let targets = route.targetRoutineIDs Task { for routineID in targets { await sync.writeBackMachineSettings(updated, exerciseName: exerciseName, routineID: routineID) } } } } } private func edit(group: SettingsGroup) { settingsEdit = SettingsEditRoute(initial: group.settings, targetRoutineIDs: group.routineIDs) } } /// One distinct recorded settings list and the routines it came from (the edit targets). fileprivate struct SettingsGroup { var label: String? var settings: [MachineSetting] var routineIDs: [String] } /// Sheet route: the settings to seed the editor with and the routines a save writes to. private struct SettingsEditRoute: Identifiable { let id = UUID() var initial: [MachineSetting] var targetRoutineIDs: [String] } /// The user's recorded machine comfort settings, set apart from the reference text /// as a card: name–value rows, grouped per routine 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), with an Add button when /// the exercise lives in at least one routine (`onAdd` non-nil). A single group edits /// from the header; disagreeing groups edit per group. private struct MachineSettingsCard: View { let groups: [SettingsGroup] var onEdit: (SettingsGroup) -> Void var onAdd: (() -> Void)? = nil var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { Text("Your Machine Settings") .font(.headline) Spacer() if groups.count == 1 { Button("Edit") { onEdit(groups[0]) } .font(.subheadline) } else if groups.isEmpty, let onAdd { Button("Add") { onAdd() } .font(.subheadline) } } if groups.isEmpty { Text(onAdd != nil ? "None recorded yet — save the seat, pad, and pin positions once and they'll be here next time." : "None recorded yet. Add this exercise to a routine, then record its settings here or from 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 { HStack { Text(label) .font(.caption) .foregroundStyle(.secondary) Spacer() if groups.count > 1 { Button("Edit") { onEdit(group) } .font(.caption) } } } 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. Internal so the debug figure-review /// screen can reuse it. 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 } }