An exercise's name is now a per-routine display name: a new optional libraryName on ExerciseDocument (snapshotted onto WorkoutLogDocument at plan time) keeps the link to the bundled library exercise, and every figure/info/cue lookup resolves libraryName ?? name. Deliberately not schema-bumped — an older app dropping the key only strands the figure link, same rationale as activityType. Cache schema bumped to 9 for the new columns. The picker no longer filters out exercises already in the routine (an "×N" badge marks them instead), exercise rows gain a leading Duplicate swipe that clones an entry in place, and the edit sheet gets a Name field with the library exercise shown read-only above it. Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
283 lines
10 KiB
Swift
283 lines
10 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. Guarded — even `id` is a persisted property and traps when read
|
|
// on an entity a remote delete has already reaped.
|
|
if workout.isLive {
|
|
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. The observed expression is evaluated on
|
|
// every body pass, so it must be liveness-guarded like any persisted read.
|
|
.onChange(of: workout.isLive ? workout.updatedAt : nil) { _, _ in
|
|
absorbExternalUpdate()
|
|
}
|
|
}
|
|
|
|
/// Pull an externally-changed workout into the working copy so the plan and notes
|
|
/// update without leaving and re-entering the screen. Skipped if the entity died
|
|
/// (remote delete) — mapping a dead @Model traps.
|
|
private func absorbExternalUpdate() {
|
|
if let fresh = WorkoutDocument(fromLive: workout) { doc = fresh }
|
|
}
|
|
|
|
@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.libraryExerciseName)?.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
|
|
/// routine'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.logs[i].touch()
|
|
doc.updatedAt = Date()
|
|
let snapshot = doc
|
|
let exerciseName = doc.logs[i].exerciseName
|
|
let routineID = doc.routineID
|
|
Task {
|
|
await sync.save(workout: snapshot)
|
|
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, routineID: routineID)
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|