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:
@@ -91,6 +91,11 @@ struct ExerciseProgressView: View {
|
||||
/// so completing the exercise from inside the flow doesn't swap the page mid-dismiss.
|
||||
@State private var startsCompleted: Bool
|
||||
|
||||
/// True when the exercise was skipped (workout ended early) when this screen opened —
|
||||
/// it shows a static Skipped page instead of the timer flow. Fixed at init like
|
||||
/// `startsCompleted`.
|
||||
@State private var startsSkipped: Bool
|
||||
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) {
|
||||
self._doc = doc
|
||||
self.logID = logID
|
||||
@@ -109,6 +114,7 @@ struct ExerciseProgressView: View {
|
||||
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
|
||||
_startsResumed = State(initialValue: !notStarted)
|
||||
_startsCompleted = State(initialValue: log?.status == WorkoutStatus.completed.rawValue)
|
||||
_startsSkipped = State(initialValue: log?.status == WorkoutStatus.skipped.rawValue)
|
||||
|
||||
let base = 1
|
||||
// Resume on the first unfinished set's work page (clamped to the last set).
|
||||
@@ -189,9 +195,21 @@ struct ExerciseProgressView: View {
|
||||
|
||||
var body: some View {
|
||||
if startsCompleted {
|
||||
CompletedPhaseView()
|
||||
.navigationTitle(log?.exerciseName ?? "")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
// Same top/bottom split as the run flow, with the Completed badge where
|
||||
// the paged flow would be, so the form-guide figure stays on screen.
|
||||
VStack(spacing: 0) {
|
||||
CompletedPhaseView(log: log)
|
||||
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
|
||||
}
|
||||
.navigationTitle(log?.exerciseName ?? "")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} else if startsSkipped {
|
||||
VStack(spacing: 0) {
|
||||
SkippedPhaseView()
|
||||
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
|
||||
}
|
||||
.navigationTitle(log?.exerciseName ?? "")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} else {
|
||||
flowBody
|
||||
}
|
||||
@@ -462,7 +480,7 @@ struct ExerciseProgressView: View {
|
||||
private func beginExercise() {
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||
guard doc.logs[i].status == WorkoutStatus.notStarted.rawValue else { return }
|
||||
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
|
||||
doc.logs[i].transition(to: .inProgress)
|
||||
recomputeWorkoutStatus()
|
||||
doc.updatedAt = Date()
|
||||
onChange()
|
||||
@@ -476,8 +494,7 @@ struct ExerciseProgressView: View {
|
||||
if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
|
||||
doc.logs[i].sets = newCount
|
||||
doc.logs[i].currentStateIndex = newCount - 1 // every prior set is now complete
|
||||
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
|
||||
doc.logs[i].completed = false
|
||||
doc.logs[i].transition(to: .inProgress)
|
||||
recomputeWorkoutStatus()
|
||||
doc.updatedAt = Date()
|
||||
onChange()
|
||||
@@ -504,8 +521,7 @@ struct ExerciseProgressView: View {
|
||||
guard reached > doc.logs[i].currentStateIndex else { return }
|
||||
|
||||
doc.logs[i].currentStateIndex = reached
|
||||
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
|
||||
doc.logs[i].completed = false
|
||||
doc.logs[i].transition(to: .inProgress)
|
||||
|
||||
recomputeWorkoutStatus()
|
||||
doc.updatedAt = Date()
|
||||
@@ -518,11 +534,9 @@ struct ExerciseProgressView: View {
|
||||
let log = doc.logs[i]
|
||||
// Skip the write if it's already pristine (e.g. landing on Ready before any set).
|
||||
guard log.currentStateIndex != 0
|
||||
|| log.status != WorkoutStatus.notStarted.rawValue
|
||||
|| log.completed else { return }
|
||||
|| log.status != WorkoutStatus.notStarted.rawValue else { return }
|
||||
doc.logs[i].currentStateIndex = 0
|
||||
doc.logs[i].status = WorkoutStatus.notStarted.rawValue
|
||||
doc.logs[i].completed = false
|
||||
doc.logs[i].transition(to: .notStarted)
|
||||
recomputeWorkoutStatus()
|
||||
doc.updatedAt = Date()
|
||||
onChange()
|
||||
@@ -531,8 +545,7 @@ struct ExerciseProgressView: View {
|
||||
private func completeExercise() {
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||
doc.logs[i].currentStateIndex = setCount
|
||||
doc.logs[i].status = WorkoutStatus.completed.rawValue
|
||||
doc.logs[i].completed = true
|
||||
doc.logs[i].transition(to: .completed)
|
||||
|
||||
recomputeWorkoutStatus()
|
||||
doc.updatedAt = Date()
|
||||
@@ -696,15 +709,71 @@ private extension View {
|
||||
|
||||
// MARK: - Completed Phase
|
||||
|
||||
/// Shown instead of the run flow when the exercise was already completed on open.
|
||||
/// Shown instead of the run flow when the exercise was already completed on open:
|
||||
/// the badge, then when it was completed, what was done, and how long it took
|
||||
/// (the timing lines appear only on logs that carry the timestamps).
|
||||
private struct CompletedPhaseView: View {
|
||||
let log: WorkoutLogDocument?
|
||||
|
||||
/// "4 sets × 12 reps" / "3 sets × 45 sec".
|
||||
private var planSummary: String {
|
||||
guard let log else { return "" }
|
||||
let setsText = "\(log.sets) set\(log.sets == 1 ? "" : "s")"
|
||||
if LoadType(rawValue: log.loadType) == .duration {
|
||||
return "\(setsText) × \(ExerciseProgressView.durationLabel(log.durationSeconds))"
|
||||
}
|
||||
return "\(setsText) × \(log.reps) reps"
|
||||
}
|
||||
|
||||
/// Start-to-done wall time, when both timestamps were recorded.
|
||||
private var elapsed: String? {
|
||||
guard let log, let start = log.startedAt, let end = log.completedAt else { return nil }
|
||||
let seconds = Int(end.timeIntervalSince(start))
|
||||
guard seconds > 0 else { return nil }
|
||||
return ExerciseProgressView.durationLabel(seconds)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 96))
|
||||
.foregroundStyle(.green)
|
||||
.font(.system(size: 96, weight: .bold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Text("Completed")
|
||||
.font(.system(.title, design: .rounded, weight: .heavy))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
|
||||
if let log {
|
||||
VStack(spacing: 4) {
|
||||
if let completedAt = log.completedAt {
|
||||
Text(completedAt.formattedRelativeDateTime())
|
||||
}
|
||||
Text(planSummary)
|
||||
if let elapsed {
|
||||
Text("in \(elapsed)")
|
||||
}
|
||||
}
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Skipped Phase
|
||||
|
||||
/// Shown instead of the run flow when the exercise was skipped (the workout was
|
||||
/// ended early with this one unfinished). Same badge treatment as Completed,
|
||||
/// in gray, matching the list row's skipped icon.
|
||||
private struct SkippedPhaseView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "wrongwaysign")
|
||||
.font(.system(size: 96, weight: .bold))
|
||||
.foregroundStyle(.gray)
|
||||
Text("Skipped")
|
||||
.font(.system(.title, design: .rounded, weight: .heavy))
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user