Files
workouts/Workouts/Views/Exercises/ExerciseLibraryView.swift
T

286 lines
12 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 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<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
/// 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: namevalue 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))
}
}