Record per-set actuals and drive the chart and volume from them
Every completed set now writes a SetEntry (reps/weight or seconds), pre-filled from the plan by transition(to:) so the list checkbox, both run flows, and One More all capture for free; reset clears, skip keeps partials. The rest and finish pages show the just-done set as a pill that opens a stepper sheet for correcting reps and weight (2.5 lb / 1.25 kg steps). The Weight Progression chart plots the top-set actual weight and workout volume sums recorded sets, both falling back to the plan for legacy logs via effectiveSetEntries. Storage side of UX #3 rides along: plan weights are Double now. Schema bumps: SplitDocument 2→3, WorkoutDocument 3→4 (a fractional weight fails an older Int decode, and a rewrite would strip the irreplaceable actuals), SwiftData cache 4→5. A per-log updatedAt is reserved for the future cross-device log merge. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
@@ -28,6 +28,7 @@ import UIKit
|
||||
/// teal for rest, with the current phase drawn as a wider dash.
|
||||
struct ExerciseProgressView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
/// The shared working workout document owned by the parent list. We mutate the
|
||||
/// matching log in place and ask the parent to persist each change — driving the UI
|
||||
@@ -66,6 +67,12 @@ struct ExerciseProgressView: View {
|
||||
/// end anchor the watch's mirror counts off.
|
||||
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||
|
||||
/// Display unit for the adjust affordance (stored weights stay unit-less).
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
/// Presents the compact adjust sheet for the just-completed set's entry.
|
||||
@State private var showingAdjust = false
|
||||
|
||||
/// Planned set count for this run. `One More` bumps it (and the log's `sets`).
|
||||
@State private var setCount: Int
|
||||
@State private var currentPage: Int
|
||||
@@ -204,18 +211,28 @@ struct ExerciseProgressView: View {
|
||||
return detail.isEmpty ? setsText : "\(setsText) × \(detail)"
|
||||
}
|
||||
|
||||
/// The half-and-half split: top/bottom in portrait, side-by-side in landscape
|
||||
/// (compact vertical size class), so neither half ends up squeezed into a
|
||||
/// short rotated band. `AnyLayout` preserves view identity across rotation —
|
||||
/// the paged flow's position and running timers survive the swap.
|
||||
private var splitLayout: AnyLayout {
|
||||
verticalSizeClass == .compact
|
||||
? AnyLayout(HStackLayout(spacing: 0))
|
||||
: AnyLayout(VStackLayout(spacing: 0))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if startsCompleted {
|
||||
// Same top/bottom split as the run flow, with the Completed badge where
|
||||
// Same half-and-half 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) {
|
||||
splitLayout {
|
||||
CompletedPhaseView(log: log)
|
||||
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
|
||||
}
|
||||
.navigationTitle(log?.exerciseName ?? "")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} else if startsSkipped {
|
||||
VStack(spacing: 0) {
|
||||
splitLayout {
|
||||
SkippedPhaseView()
|
||||
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
|
||||
}
|
||||
@@ -227,7 +244,7 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
|
||||
private var flowBody: some View {
|
||||
VStack(spacing: 0) {
|
||||
splitLayout {
|
||||
// Paged flow — top half.
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(0..<totalPages, id: \.self) { index in
|
||||
@@ -443,12 +460,15 @@ struct ExerciseProgressView: View {
|
||||
let cycleIndex = index - base
|
||||
if cycleIndex == cycleCount {
|
||||
// Finish page — confirm Done (auto-fires) or add One More.
|
||||
FinishPhaseView(
|
||||
isActive: isActive,
|
||||
onDone: { completeExercise(); dismiss() },
|
||||
onOneMore: addSet,
|
||||
anchorEnd: anchorEnd
|
||||
)
|
||||
VStack(spacing: 0) {
|
||||
FinishPhaseView(
|
||||
isActive: isActive,
|
||||
onDone: { completeExercise(); dismiss() },
|
||||
onOneMore: addSet,
|
||||
anchorEnd: anchorEnd
|
||||
)
|
||||
adjustPill
|
||||
}
|
||||
} else if cycleIndex.isMultiple(of: 2) {
|
||||
let setNumber = cycleIndex / 2 + 1
|
||||
if isDuration {
|
||||
@@ -476,20 +496,75 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
} else {
|
||||
// Rest phase. Auto-advances to the next work page when the timer hits zero.
|
||||
CountdownPhaseView(
|
||||
header: "Rest",
|
||||
tint: .restTimer,
|
||||
seconds: restSeconds,
|
||||
isActive: isActive,
|
||||
anchorStart: anchorStart,
|
||||
anchorEnd: anchorEnd
|
||||
) {
|
||||
withAnimation { advance(from: index) }
|
||||
VStack(spacing: 0) {
|
||||
CountdownPhaseView(
|
||||
header: "Rest",
|
||||
tint: .restTimer,
|
||||
seconds: restSeconds,
|
||||
isActive: isActive,
|
||||
anchorStart: anchorStart,
|
||||
anchorEnd: anchorEnd
|
||||
) {
|
||||
withAnimation { advance(from: index) }
|
||||
}
|
||||
adjustPill
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Adjust the last set's entry
|
||||
|
||||
/// The just-completed set's recorded entry, shown on the rest and finish pages
|
||||
/// as a tappable pill — the log-by-default record with a correct-by-exception
|
||||
/// affordance. Dead time by design: it never blocks the countdown or auto-advance.
|
||||
@ViewBuilder
|
||||
private var adjustPill: some View {
|
||||
if let entry = log?.setEntries?.last {
|
||||
Button {
|
||||
showingAdjust = true
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(SetEntryFormat.summary(entry, weightUnit: weightUnit))
|
||||
Image(systemName: "pencil")
|
||||
.font(.caption)
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.background(.quaternary.opacity(0.5), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.bottom, 32) // clear the phase-dot row overlaid at the bottom
|
||||
.sheet(isPresented: $showingAdjust) { adjustSheet }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var adjustSheet: some View {
|
||||
if let log, let entry = log.setEntries?.last {
|
||||
SetEntryAdjustSheet(
|
||||
entry: entry,
|
||||
setNumber: log.setEntries?.count ?? 1,
|
||||
loadType: LoadType(rawValue: log.loadType) ?? .weight,
|
||||
weightUnit: weightUnit,
|
||||
onChange: updateLastEntry
|
||||
)
|
||||
.presentationDetents([.height(300)])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an adjusted value back onto the last recorded entry and persist.
|
||||
private func updateLastEntry(_ entry: SetEntry) {
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }),
|
||||
let last = doc.logs[i].setEntries?.indices.last else { return }
|
||||
doc.logs[i].setEntries?[last] = entry
|
||||
doc.updatedAt = Date()
|
||||
onChange()
|
||||
}
|
||||
|
||||
// MARK: - Mutations
|
||||
|
||||
/// Leave the Ready page for the first work phase, marking the exercise started.
|
||||
@@ -525,6 +600,9 @@ 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
|
||||
// The old final set just became a completed *prior* set — record its entry.
|
||||
// The bonus set's own entry lands when it completes, not now.
|
||||
doc.logs[i].fillSetEntries(upTo: newCount - 1, at: Date())
|
||||
doc.logs[i].transition(to: .inProgress)
|
||||
recomputeWorkoutStatus()
|
||||
doc.updatedAt = Date()
|
||||
@@ -552,6 +630,9 @@ struct ExerciseProgressView: View {
|
||||
guard reached > doc.logs[i].currentStateIndex else { return }
|
||||
|
||||
doc.logs[i].currentStateIndex = reached
|
||||
// Log-by-default: each newly-completed set gets a plan-filled entry
|
||||
// (append-only — swiping back never removes one; adjust corrects the last).
|
||||
doc.logs[i].fillSetEntries(upTo: reached, at: Date())
|
||||
doc.logs[i].transition(to: .inProgress)
|
||||
|
||||
recomputeWorkoutStatus()
|
||||
@@ -589,7 +670,7 @@ struct ExerciseProgressView: View {
|
||||
|
||||
// MARK: - Formatting
|
||||
|
||||
static func durationLabel(_ seconds: Int) -> String {
|
||||
nonisolated static func durationLabel(_ seconds: Int) -> String {
|
||||
let mins = seconds / 60
|
||||
let secs = seconds % 60
|
||||
if mins > 0 && secs > 0 { return "\(mins)m \(secs)s" }
|
||||
@@ -598,6 +679,116 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Set entry formatting
|
||||
|
||||
/// Renders recorded `SetEntry` actuals for display (the pill, the Completed page).
|
||||
/// Which fields an entry carries follows the log's `LoadType`, so formatting keys
|
||||
/// off the populated fields rather than re-deriving the type.
|
||||
enum SetEntryFormat {
|
||||
/// One entry: "10 reps · 45 lb", "10 reps", or "45 sec".
|
||||
static func summary(_ entry: SetEntry, weightUnit: WeightUnit) -> String {
|
||||
var parts: [String] = []
|
||||
if let reps = entry.reps { parts.append("\(reps) reps") }
|
||||
if let weight = entry.weight { parts.append(weightUnit.format(weight)) }
|
||||
if let seconds = entry.seconds { parts.append(ExerciseProgressView.durationLabel(seconds)) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// All of a log's entries on one line: "10 · 8 · 6 reps @ 45 lb" when the
|
||||
/// weight is uniform, a per-set "10 × 45 lb · 8 × 42.5 lb" list when it varies,
|
||||
/// "45 · 45 · 30 sec" for durations, "10 · 8 · 6 reps" for bodyweight.
|
||||
static func actualsLine(_ entries: [SetEntry], weightUnit: WeightUnit) -> String {
|
||||
guard !entries.isEmpty else { return "" }
|
||||
if entries.contains(where: { $0.seconds != nil }) {
|
||||
return entries.map { "\($0.seconds ?? 0)" }.joined(separator: " · ") + " sec"
|
||||
}
|
||||
let repsList = entries.map { "\($0.reps ?? 0)" }.joined(separator: " · ")
|
||||
let weights = entries.compactMap(\.weight)
|
||||
guard !weights.isEmpty else { return repsList + " reps" }
|
||||
if Set(weights).count == 1 {
|
||||
return "\(repsList) reps @ \(weightUnit.format(weights[0]))"
|
||||
}
|
||||
return entries.map { entry in
|
||||
guard let weight = entry.weight else { return "\(entry.reps ?? 0)" }
|
||||
return "\(entry.reps ?? 0) × \(weightUnit.format(weight))"
|
||||
}.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Set entry adjust sheet
|
||||
|
||||
/// Compact steppers for correcting the just-completed set's entry — reps and
|
||||
/// weight for a weighted set, reps alone for bodyweight, seconds for a timed
|
||||
/// hold. Changes apply live (via `onChange`), so a swipe-down needs no confirm.
|
||||
private struct SetEntryAdjustSheet: View {
|
||||
let setNumber: Int
|
||||
let loadType: LoadType
|
||||
let weightUnit: WeightUnit
|
||||
let onChange: (SetEntry) -> Void
|
||||
|
||||
@State private var entry: SetEntry
|
||||
|
||||
init(entry: SetEntry, setNumber: Int, loadType: LoadType, weightUnit: WeightUnit,
|
||||
onChange: @escaping (SetEntry) -> Void) {
|
||||
_entry = State(initialValue: entry)
|
||||
self.setNumber = setNumber
|
||||
self.loadType = loadType
|
||||
self.weightUnit = weightUnit
|
||||
self.onChange = onChange
|
||||
}
|
||||
|
||||
/// Plate-math step in the *display* unit (the stored value stays unit-less).
|
||||
private var weightStep: Double { weightUnit == .kg ? 1.25 : 2.5 }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Text("Set \(setNumber)")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
switch loadType {
|
||||
case .weight:
|
||||
repsStepper
|
||||
weightStepper
|
||||
case .none:
|
||||
repsStepper
|
||||
case .duration:
|
||||
secondsStepper
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.onChange(of: entry) { _, updated in onChange(updated) }
|
||||
}
|
||||
|
||||
private var repsStepper: some View {
|
||||
Stepper(value: Binding(get: { entry.reps ?? 0 }, set: { entry.reps = $0 }),
|
||||
in: 0...200) {
|
||||
Text("\(entry.reps ?? 0) reps")
|
||||
.font(.title3.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
private var weightStepper: some View {
|
||||
Stepper(value: Binding(get: { entry.weight ?? 0 }, set: { entry.weight = $0 }),
|
||||
in: 0...2000, step: weightStep) {
|
||||
Text(weightUnit.format(entry.weight ?? 0))
|
||||
.font(.title3.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
private var secondsStepper: some View {
|
||||
Stepper(value: Binding(get: { entry.seconds ?? 0 }, set: { entry.seconds = $0 }),
|
||||
in: 0...3600, step: 5) {
|
||||
Text(ExerciseProgressView.durationLabel(entry.seconds ?? 0))
|
||||
.font(.title3.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Haptics
|
||||
|
||||
/// Maps the watch flow's haptic vocabulary onto UIKit feedback generators so the iPhone
|
||||
@@ -750,6 +941,8 @@ private extension View {
|
||||
private struct CompletedPhaseView: View {
|
||||
let log: WorkoutLogDocument?
|
||||
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
/// Scales the completion badge with Dynamic Type.
|
||||
@ScaledMetric(relativeTo: .largeTitle) private var badgeSize: CGFloat = 96
|
||||
|
||||
@@ -785,7 +978,13 @@ private struct CompletedPhaseView: View {
|
||||
if let completedAt = log.completedAt {
|
||||
Text(completedAt.formattedRelativeDateTime())
|
||||
}
|
||||
Text(planSummary)
|
||||
// Recorded actuals ("10 · 8 · 6 reps @ 45 lb") beat the plan
|
||||
// summary; legacy logs without entries keep showing the plan.
|
||||
if let entries = log.setEntries, !entries.isEmpty {
|
||||
Text(SetEntryFormat.actualsLine(entries, weightUnit: weightUnit))
|
||||
} else {
|
||||
Text(planSummary)
|
||||
}
|
||||
if let elapsed {
|
||||
Text("in \(elapsed)")
|
||||
}
|
||||
|
||||
@@ -136,7 +136,9 @@ struct PlanEditView: View {
|
||||
if let log = WorkoutDocument(from: workout).logs.first(where: { $0.id == logID }) {
|
||||
sets = log.sets
|
||||
reps = log.reps
|
||||
weight = log.weight
|
||||
// The picker steps whole values; a fractional stored weight
|
||||
// shows (and saves back) its whole part until UX #3's UI lands.
|
||||
weight = Int(log.weight)
|
||||
durationMinutes = log.durationSeconds / 60
|
||||
durationSeconds = log.durationSeconds % 60
|
||||
selectedLoadType = LoadType(rawValue: log.loadType) ?? .weight
|
||||
@@ -153,7 +155,7 @@ struct PlanEditView: View {
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||
doc.logs[i].sets = sets
|
||||
doc.logs[i].reps = reps
|
||||
doc.logs[i].weight = weight
|
||||
doc.logs[i].weight = Double(weight)
|
||||
doc.logs[i].durationSeconds = totalSeconds
|
||||
doc.logs[i].loadType = selectedLoadType.rawValue
|
||||
doc.updatedAt = Date()
|
||||
@@ -170,7 +172,7 @@ struct PlanEditView: View {
|
||||
if let ei = sDoc.exercises.firstIndex(where: { $0.name == exerciseName }) {
|
||||
sDoc.exercises[ei].sets = sets
|
||||
sDoc.exercises[ei].reps = reps
|
||||
sDoc.exercises[ei].weight = weight
|
||||
sDoc.exercises[ei].weight = Double(weight)
|
||||
sDoc.exercises[ei].durationSeconds = totalSeconds
|
||||
sDoc.exercises[ei].loadType = selectedLoadType.rawValue
|
||||
sDoc.updatedAt = Date()
|
||||
|
||||
@@ -34,7 +34,12 @@ struct WeightProgressionChartView: View {
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
private var weightData: [WeightDataPoint] {
|
||||
logs.map { WeightDataPoint(date: $0.date, weight: $0.weight) }
|
||||
// Top-set *actual* weight per completed log; legacy logs without recorded
|
||||
// entries fall back to the planned weight (the old behavior).
|
||||
logs.map { log in
|
||||
let topSet = log.setEntries?.compactMap(\.weight).max()
|
||||
return WeightDataPoint(date: log.date, weight: topSet ?? log.weight)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -100,7 +105,7 @@ struct WeightProgressionChartView: View {
|
||||
|
||||
if weightDifference > 0 {
|
||||
let percentIncrease = firstWeight > 0
|
||||
? Int((Double(weightDifference) / Double(firstWeight)) * 100)
|
||||
? Int((weightDifference / firstWeight) * 100)
|
||||
: 0
|
||||
if percentIncrease >= 20 {
|
||||
return "Amazing progress! You've increased your weight by \(weightUnit.format(weightDifference)) (\(percentIncrease)%)!"
|
||||
@@ -121,5 +126,5 @@ struct WeightProgressionChartView: View {
|
||||
struct WeightDataPoint: Identifiable {
|
||||
let id = UUID()
|
||||
let date: Date
|
||||
let weight: Int
|
||||
let weight: Double
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user