Add machine comfort settings and tighten the document schemas

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
This commit is contained in:
2026-07-06 16:29:44 -04:00
parent fce8fa4c17
commit 2c1e4759ae
33 changed files with 1132 additions and 586 deletions
+8 -104
View File
@@ -12,35 +12,25 @@ import SwiftData
import Charts
struct ExerciseView: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Environment(\.dismiss) private var dismiss
let workout: Workout
let logID: String
/// Working copy of the parent workout. Editing a log = editing this doc and
/// re-saving the whole aggregate. Driving the UI from local state (not the
/// cache entity) keeps rapid set taps from racing the filecache update.
/// 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 filecache
/// round-trip catches up.
@State private var doc: WorkoutDocument
@State private var progress: Int = 0
@State private var showingPlanEdit = false
@State private var showingNotesEdit = false
let notStartedColor = Color.white
let completedColor = Color.green
/// `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 filecache round-trip to find the just-created log.
init(workout: Workout, logID: String, seedDoc: WorkoutDocument? = nil) {
self.workout = workout
self.logID = logID
let initialDoc = seedDoc ?? WorkoutDocument(from: workout)
_doc = State(initialValue: initialDoc)
// Seed progress from the log so the set grid is correct on the first frame
// (onAppear also refreshes it, but that lags the initial render).
_progress = State(initialValue: initialDoc.logs.first { $0.id == logID }?.currentStateIndex ?? 0)
_doc = State(initialValue: seedDoc ?? WorkoutDocument(from: workout))
}
/// The log being edited within the working doc.
@@ -66,7 +56,6 @@ struct ExerciseView: View {
}
.onAppear {
refreshDocIfNeeded()
progress = log?.currentStateIndex ?? 0
// Take over this run: the watch parks and locks it while we're editing here.
services.watchBridge.setEditingWorkout(workout.id)
}
@@ -81,60 +70,15 @@ struct ExerciseView: View {
}
}
/// Pull an externally-changed workout into the working copy so the set grid and plan
/// 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() {
let fresh = WorkoutDocument(from: workout)
doc = fresh
progress = fresh.logs.first(where: { $0.id == logID })?.currentStateIndex ?? progress
doc = WorkoutDocument(from: workout)
}
@ViewBuilder
private func content(for log: WorkoutLogDocument) -> some View {
Form {
// MARK: - Progress Section
Section(header: Text("Progress")) {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: max(1, log.sets)), spacing: 4) {
ForEach(1...max(1, log.sets), id: \.self) { index in
ZStack {
let completed = index <= progress
let color = completed ? completedColor : notStartedColor
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
gradient: Gradient(colors: [color, color.darker(by: 0.2)]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.aspectRatio(0.618, contentMode: .fit)
.shadow(radius: 2)
Text("\(index)")
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary)
.colorInvert()
}
.onTapGesture {
let totalSets = log.sets
let isLastTile = index == totalSets
let wasAlreadyAtThisProgress = progress == index
withAnimation(.easeInOut(duration: 0.2)) {
progress = wasAlreadyAtThisProgress ? 0 : index
}
updateLogStatus()
// Tapping the final tile to complete returns to the list.
if isLastTile && !wasAlreadyAtThisProgress {
dismiss()
}
}
}
}
}
// MARK: - Plan Section (Read-only with Edit button)
Section {
PlanTilesView(log: log)
@@ -186,34 +130,6 @@ struct ExerciseView: View {
}
}
// MARK: - Mutations
private func updateLogStatus() {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].currentStateIndex = progress
if progress >= doc.logs[i].sets {
doc.logs[i].status = WorkoutStatus.completed.rawValue
doc.logs[i].completed = true
} else if progress > 0 {
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
doc.logs[i].completed = false
} else {
doc.logs[i].status = WorkoutStatus.notStarted.rawValue
doc.logs[i].completed = false
}
recomputeWorkoutStatus()
doc.updatedAt = Date()
let snapshot = doc
Task { await sync.save(workout: snapshot) }
}
/// Recompute the workout's status/end from its logs.
private func recomputeWorkoutStatus() {
doc.recomputeStatusFromLogs()
}
/// 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() {
@@ -222,21 +138,9 @@ struct ExerciseView: View {
}
/// Re-read the workout from the cache to absorb edits made by child sheets
/// (plan/notes) without clobbering progress edits made here.
/// (plan/notes).
private func refreshDocFromCache() {
let fresh = WorkoutDocument(from: workout)
// Preserve the locally edited progress for the open log if the cache lags.
if let i = fresh.logs.firstIndex(where: { $0.id == logID }),
let mine = doc.logs.first(where: { $0.id == logID }),
fresh.logs[i].currentStateIndex != mine.currentStateIndex {
doc = fresh
doc.logs[i].currentStateIndex = mine.currentStateIndex
} else {
doc = fresh
}
if let current = log {
progress = current.currentStateIndex
}
doc = WorkoutDocument(from: workout)
}
}