Library detail screens now lead with the user's recorded machine settings (per-split when they disagree, empty-state card for machine-based entries) and append the weight progression chart. Starter seeds mark machine exercises with an empty machineSettings list so the settings UI lights up before first use. The figure rig gains a frontal body profile for face-on machines, props that can ride mid joints (knees/elbows), and an alternating four-frame Bird Dog loop. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
276 lines
9.8 KiB
Swift
276 lines
9.8 KiB
Swift
//
|
|
// ExerciseView.swift
|
|
// Workouts
|
|
//
|
|
// Created by rzen on 7/18/25 at 5:44 PM.
|
|
//
|
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
import Charts
|
|
|
|
struct ExerciseView: View {
|
|
@Environment(SyncEngine.self) private var sync
|
|
@Environment(AppServices.self) private var services
|
|
|
|
let workout: Workout
|
|
let logID: String
|
|
|
|
/// Working copy of the parent workout. Driving the UI from local state (not the
|
|
/// cache entity) lets `seedDoc` show a just-added log before the file→cache
|
|
/// round-trip catches up.
|
|
@State private var doc: WorkoutDocument
|
|
@State private var showingPlanEdit = false
|
|
@State private var showingNotesEdit = false
|
|
@State private var showingMachineSettings = false
|
|
|
|
/// `seedDoc` lets the caller hand over an in-memory document (e.g. the parent's
|
|
/// working copy right after adding an exercise) so the screen doesn't wait on
|
|
/// the file→cache round-trip to find the just-created log.
|
|
init(workout: Workout, logID: String, seedDoc: WorkoutDocument? = nil) {
|
|
self.workout = workout
|
|
self.logID = logID
|
|
_doc = State(initialValue: seedDoc ?? WorkoutDocument(from: workout))
|
|
}
|
|
|
|
/// The log being edited within the working doc.
|
|
private var log: WorkoutLogDocument? {
|
|
doc.logs.first { $0.id == logID }
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let log {
|
|
// Deliberately NOT the half-and-half figure layout: this screen is
|
|
// dense with the plan, notes, settings, and the progression chart —
|
|
// the form guide lives in the run flow and the exercise library.
|
|
content(for: log)
|
|
} else {
|
|
// The just-added log hasn't reached the cache yet; refresh shortly.
|
|
ProgressView()
|
|
}
|
|
}
|
|
.navigationTitle(log?.exerciseName ?? "")
|
|
.sheet(isPresented: $showingPlanEdit) {
|
|
PlanEditView(workout: workout, logID: logID)
|
|
}
|
|
.sheet(isPresented: $showingNotesEdit) {
|
|
NotesEditView(workout: workout, logID: logID)
|
|
}
|
|
.sheet(isPresented: $showingMachineSettings) {
|
|
if let log {
|
|
MachineSettingsSheet(
|
|
exerciseName: log.exerciseName,
|
|
initialSettings: log.machineSettings ?? []
|
|
) { updated in
|
|
applyMachineSettings(updated)
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
refreshDocIfNeeded()
|
|
// Take over this run: the watch parks and locks it while we're editing here.
|
|
services.watchBridge.setEditingWorkout(workout.id)
|
|
}
|
|
.onDisappear {
|
|
services.watchBridge.setEditingWorkout(nil)
|
|
}
|
|
// Reflect external changes (e.g. a set completed on the watch) live. Each edit
|
|
// rewrites the whole workout file, so the cache always holds the latest — pulling
|
|
// it in can't revert a newer local tap.
|
|
.onChange(of: workout.updatedAt) { _, _ in
|
|
absorbExternalUpdate()
|
|
}
|
|
}
|
|
|
|
/// Pull an externally-changed workout into the working copy so the plan and notes
|
|
/// update without leaving and re-entering the screen.
|
|
private func absorbExternalUpdate() {
|
|
doc = WorkoutDocument(from: workout)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func content(for log: WorkoutLogDocument) -> some View {
|
|
Form {
|
|
// MARK: - Plan Section (Read-only with Edit button)
|
|
Section {
|
|
PlanTilesView(log: log)
|
|
} header: {
|
|
HStack {
|
|
Text("Plan")
|
|
Spacer()
|
|
Button("Edit") {
|
|
showingPlanEdit = true
|
|
}
|
|
.font(.subheadline)
|
|
.textCase(.none)
|
|
}
|
|
}
|
|
|
|
// MARK: - Machine Settings — shown when the log carries settings, or for
|
|
// any exercise the authored library identifies as machine-based, so the
|
|
// section is discoverable before settings are first recorded.
|
|
if log.machineSettings != nil
|
|
|| ExerciseInfoLibrary.info(for: log.exerciseName)?.isMachineBased == true {
|
|
let settings = log.machineSettings ?? []
|
|
Section {
|
|
if settings.isEmpty {
|
|
Text("No settings recorded")
|
|
.foregroundColor(.secondary)
|
|
.italic()
|
|
} else {
|
|
ForEach(Array(settings.enumerated()), id: \.offset) { _, setting in
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text(setting.name)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(setting.value)
|
|
.fontWeight(.semibold)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
HStack {
|
|
Text("Machine Settings")
|
|
Spacer()
|
|
Button("Edit") {
|
|
showingMachineSettings = true
|
|
}
|
|
.font(.subheadline)
|
|
.textCase(.none)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Notes Section (Read-only with Edit button)
|
|
Section {
|
|
if let notes = log.notes, !notes.isEmpty {
|
|
Text(notes)
|
|
.foregroundColor(.primary)
|
|
} else {
|
|
Text("No notes")
|
|
.foregroundColor(.secondary)
|
|
.italic()
|
|
}
|
|
} header: {
|
|
HStack {
|
|
Text("Notes")
|
|
Spacer()
|
|
Button("Edit") {
|
|
showingNotesEdit = true
|
|
}
|
|
.font(.subheadline)
|
|
.textCase(.none)
|
|
}
|
|
}
|
|
|
|
// MARK: - Progress Tracking Chart (weighted exercises only — a weight
|
|
// chart is meaningless for bodyweight/timed logs)
|
|
if LoadType(rawValue: log.loadType) == .weight {
|
|
Section(header: Text("Progress Tracking")) {
|
|
WeightProgressionChartView(exerciseName: log.exerciseName)
|
|
}
|
|
}
|
|
}
|
|
// Pull plan/notes edits made in the sheets back into the live doc.
|
|
.onChange(of: showingPlanEdit) { _, presenting in
|
|
if !presenting { refreshDocFromCache() }
|
|
}
|
|
.onChange(of: showingNotesEdit) { _, presenting in
|
|
if !presenting { refreshDocFromCache() }
|
|
}
|
|
}
|
|
|
|
/// Apply edited machine settings: update the log's plan-time snapshot (persisted
|
|
/// with the workout), then write the same settings back to the originating
|
|
/// split's exercise as the durable default — same sequencing as the workout
|
|
/// list's machine sheet.
|
|
private func applyMachineSettings(_ settings: [MachineSetting]) {
|
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
|
doc.logs[i].machineSettings = settings
|
|
doc.updatedAt = Date()
|
|
let snapshot = doc
|
|
let exerciseName = doc.logs[i].exerciseName
|
|
let splitID = doc.splitID
|
|
Task {
|
|
await sync.save(workout: snapshot)
|
|
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID)
|
|
}
|
|
}
|
|
|
|
/// If the requested log isn't in the working doc yet (just-added race), pull a
|
|
/// fresh copy from the cache entity once it catches up.
|
|
private func refreshDocIfNeeded() {
|
|
guard log == nil else { return }
|
|
refreshDocFromCache()
|
|
}
|
|
|
|
/// Re-read the workout from the cache to absorb edits made by child sheets
|
|
/// (plan/notes).
|
|
private func refreshDocFromCache() {
|
|
doc = WorkoutDocument(from: workout)
|
|
}
|
|
}
|
|
|
|
// MARK: - Plan Tiles View
|
|
|
|
struct PlanTilesView: View {
|
|
let log: WorkoutLogDocument
|
|
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
|
|
|
var body: some View {
|
|
if LoadType(rawValue: log.loadType) == .duration {
|
|
// Duration layout: Sets | Duration
|
|
HStack(spacing: 0) {
|
|
PlanTile(label: "Sets", value: "\(log.sets)")
|
|
PlanTile(label: "Duration", value: formattedDuration)
|
|
}
|
|
} else {
|
|
// Weight layout: Sets | Reps | Weight
|
|
HStack(spacing: 0) {
|
|
PlanTile(label: "Sets", value: "\(log.sets)")
|
|
PlanTile(label: "Reps", value: "\(log.reps)")
|
|
PlanTile(label: "Weight", value: weightUnit.format(log.weight))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var formattedDuration: String {
|
|
let mins = log.durationSeconds / 60
|
|
let secs = log.durationSeconds % 60
|
|
if mins > 0 && secs > 0 {
|
|
return "\(mins)m \(secs)s"
|
|
} else if mins > 0 {
|
|
return "\(mins) min"
|
|
} else if secs > 0 {
|
|
return "\(secs) sec"
|
|
} else {
|
|
return "0 sec"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PlanTile: View {
|
|
let label: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
VStack(spacing: 4) {
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
Text(value)
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|