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
395 lines
16 KiB
Swift
395 lines
16 KiB
Swift
//
|
||
// 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: 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.
|
||
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
|
||
}
|
||
}
|