Files
workouts/Workouts/Views/Exercises/ExerciseLibraryView.swift
T
rzen 6e440317c4 Restructure into a three-tab app with Progress, goals, and Meditation
The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView
becomes a Today / Progress / Settings TabView, "Routine" replaces
"Split" in every user-facing string and view name (code-level types
keep their names), and workout starting moves to shared
WorkoutStarter / StartedWorkoutNavigator plumbing.

- New Progress tab: weekly goal streaks, workout trends, per-exercise
  weight progression, achievements, and the full history list
  (WorkoutLogsView -> WorkoutHistoryView).
- Goals: stable categories workouts roll up to, managed from Settings.
- New Meditation exercise + starter routine; timed sits record to
  Apple Health as Mind & Body sessions.

Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
2026-07-11 07:53:01 -04:00

395 lines
16 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 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("\(ExerciseMotionLibrary.exerciseNames.count) 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 {
@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))
}
}
/// 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
}
}