A fourth tab promotes routine and exercise management out of Settings. LibraryView switches between two segments on one navigation stack. The routines pane (RoutineListView reborn as RoutinesLibraryView) sorts by the user's order, gains drag-to-reorder writing only changed orders, swipe actions for duplicate/edit/delete, a Starter badge on bundled seeds, and a last-trained caption. The exercises pane groups the library into curated category sections searchable by name, category, or muscle. Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
419 lines
17 KiB
Swift
419 lines
17 KiB
Swift
//
|
||
// 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: 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
|
||
}
|
||
}
|