Machine-based exercises now carry ordered name/value comfort settings (seat height, back-rest position, ...) on both ExerciseDocument and, as a plan-time snapshot, WorkoutLogDocument — the optional array doubles as the machine flag. Editable from the exercise editor's new Machine section and from the workout row's settings sheet, whose mid-workout edits write back to the originating split (workout saved first, so seed clone-on-edit repointing can't clobber the log edit). Schema tightening rides the same rev: splits bump to v2 (weight-reminder fields and the unused exercise category removed), workouts to v3 (the derived `completed` flag removed; status is the single source). Starter seeds regenerated at v2 with unchanged ULIDs; SwiftData cache schema bumped to rebuild. SCHEMA.md documents the shapes. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
205 lines
6.5 KiB
Swift
205 lines
6.5 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(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
|
|
|
|
/// `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 {
|
|
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)
|
|
}
|
|
.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: - 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
|
|
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() }
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|