Add an "End Workout" flow for partially-done workouts

Replace the in-workout "+" toolbar button with an ellipsis menu offering
"Add Exercise" and "End Workout". Ending opens a Save/Discard action sheet:
Save marks the remaining exercises as skipped and resolves the workout to
completed (stamping end), which drops it off the watch's active list and ends
the watch's HealthKit session; Discard soft-deletes it.

Teach the status-from-logs derivation that a skipped log is terminal, and
consolidate the three duplicated copies into a single shared
WorkoutDocument.recomputeStatusFromLogs() so an ended workout stays finished
regardless of which screen the next edit comes from.

Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
This commit is contained in:
2026-06-22 18:34:14 -04:00
parent 208fa73f3d
commit e2295aa287
6 changed files with 87 additions and 49 deletions
+2
View File
@@ -1,5 +1,7 @@
**June 2026** **June 2026**
You can now end a workout before finishing every exercise: open the ⋯ menu and choose End Workout, then Save to keep what you did — any exercises you didn't get to are marked skipped — or Discard to remove the workout.
Right after you turn on iCloud Drive, Workouts now waits for it to finish coming online instead of giving up and showing the "turn on iCloud Drive" screen too soon. The connecting screen also keeps you posted as it works, letting you know the first connect can take a moment. Right after you turn on iCloud Drive, Workouts now waits for it to finish coming online instead of giving up and showing the "turn on iCloud Drive" screen too soon. The connecting screen also keeps you posted as it works, letting you know the first connect can take a moment.
When iCloud Drive is turned off, Workouts now shows a clearer, friendlier screen — explaining that your data lives privately in your own iCloud Drive, with no account or login, and walking you through turning iCloud Drive on. When iCloud Drive is turned off, Workouts now shows a clearer, friendlier screen — explaining that your data lives privately in your own iCloud Drive, with no account or login, and walking you through turning iCloud Drive on.
+3 -1
View File
@@ -14,7 +14,9 @@ your own iCloud Drive.
- **Run a workout** — start a session from a split, then tap an exercise to run it - **Run a workout** — start a session from a split, then tap an exercise to run it
as a paged flow: a **Ready?** lead-in, count-up work phases, count-down rests, and as a paged flow: a **Ready?** lead-in, count-up work phases, count-down rests, and
a **Finish** page — mirroring the Apple Watch. Swipe a row to mark it complete, or a **Finish** page — mirroring the Apple Watch. Swipe a row to mark it complete, or
swipe to edit its plan (sets/reps/weight or duration) and notes. swipe to edit its plan (sets/reps/weight or duration) and notes. End a workout early
from the **⋯** menu — **Save** keeps your progress (remaining exercises are marked
skipped) or **Discard** removes it.
- **Progress tracking** — weight-progression charts per exercise across past - **Progress tracking** — weight-progression charts per exercise across past
sessions. sessions.
- **Apple Watch companion** — starting a workout on the iPhone launches the watch - **Apple Watch companion** — starting a workout on the iPhone launches the watch
+27
View File
@@ -69,6 +69,33 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
} }
} }
extension WorkoutDocument {
/// Derive the aggregate `status` + `end` from the current logs. A log that is
/// `.completed` or `.skipped` counts as *resolved*; a workout whose logs are all
/// resolved is finished (`.completed`, with `end` stamped). This is the single
/// source of the status-from-logs rule every screen that mutates logs calls it,
/// so an ended workout (remaining exercises skipped) stays finished no matter which
/// screen the next edit comes from.
mutating func recomputeStatusFromLogs() {
let statuses: [WorkoutStatus] = logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted }
let isResolved: (WorkoutStatus) -> Bool = { $0 == .completed || $0 == .skipped }
let allResolved = !statuses.isEmpty && statuses.allSatisfy(isResolved)
let anyStarted = statuses.contains { $0 != .notStarted }
if allResolved {
status = WorkoutStatus.completed.rawValue
end = Date()
} else if anyStarted {
status = WorkoutStatus.inProgress.rawValue
end = nil
} else {
status = WorkoutStatus.notStarted.rawValue
end = nil
}
}
}
struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable { struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
var id: String // ULID var id: String // ULID
var exerciseName: String var exerciseName: String
@@ -523,21 +523,7 @@ struct ExerciseProgressView: View {
} }
private func recomputeWorkoutStatus() { private func recomputeWorkoutStatus() {
let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted } doc.recomputeStatusFromLogs()
let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
let anyInProgress = statuses.contains { $0 == .inProgress }
let allNotStarted = statuses.allSatisfy { $0 == .notStarted }
if allCompleted {
doc.status = WorkoutStatus.completed.rawValue
doc.end = Date()
} else if anyInProgress || !allNotStarted {
doc.status = WorkoutStatus.inProgress.rawValue
doc.end = nil
} else {
doc.status = WorkoutStatus.notStarted.rawValue
doc.end = nil
}
} }
// MARK: - Formatting // MARK: - Formatting
+1 -15
View File
@@ -211,21 +211,7 @@ struct ExerciseView: View {
/// Recompute the workout's status/end from its logs. /// Recompute the workout's status/end from its logs.
private func recomputeWorkoutStatus() { private func recomputeWorkoutStatus() {
let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted } doc.recomputeStatusFromLogs()
let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
let anyInProgress = statuses.contains { $0 == .inProgress }
let allNotStarted = statuses.allSatisfy { $0 == .notStarted }
if allCompleted {
doc.status = WorkoutStatus.completed.rawValue
doc.end = Date()
} else if anyInProgress || !allNotStarted {
doc.status = WorkoutStatus.inProgress.rawValue
doc.end = nil
} else {
doc.status = WorkoutStatus.notStarted.rawValue
doc.end = nil
}
} }
/// If the requested log isn't in the working doc yet (just-added race), pull a /// If the requested log isn't in the working doc yet (just-added race), pull a
@@ -15,6 +15,7 @@ struct WorkoutLogListView: View {
@Environment(AppServices.self) private var services @Environment(AppServices.self) private var services
@Environment(LiveRunState.self) private var liveRun @Environment(LiveRunState.self) private var liveRun
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
let workout: Workout let workout: Workout
@@ -25,6 +26,8 @@ struct WorkoutLogListView: View {
@State private var doc: WorkoutDocument @State private var doc: WorkoutDocument
@State private var showingAddSheet = false @State private var showingAddSheet = false
@State private var showingActions = false
@State private var showingEndOptions = false
@State private var logToDelete: WorkoutLogDocument? @State private var logToDelete: WorkoutLogDocument?
@State private var addedLog: LogRoute? @State private var addedLog: LogRoute?
@State private var logToEdit: LogRoute? @State private var logToEdit: LogRoute?
@@ -130,9 +133,9 @@ struct WorkoutLogListView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button { Button {
showingAddSheet.toggle() showingActions = true
} label: { } label: {
Image(systemName: "plus") Image(systemName: "ellipsis.circle")
} }
} }
} }
@@ -161,6 +164,28 @@ struct WorkoutLogListView: View {
logToDelete = nil logToDelete = nil
} }
} }
.confirmationDialog(
"Workout",
isPresented: $showingActions,
titleVisibility: .hidden
) {
Button("Add Exercise") { showingAddSheet = true }
if !sortedLogs.isEmpty {
Button("End Workout") { showingEndOptions = true }
}
Button("Cancel", role: .cancel) {}
}
.confirmationDialog(
"End Workout?",
isPresented: $showingEndOptions,
titleVisibility: .visible
) {
Button("Save Workout") { endWorkout() }
Button("Discard Workout", role: .destructive) { discardWorkout() }
Button("Cancel", role: .cancel) {}
} message: {
Text(endProgressMessage)
}
} }
/// The paged run flow, fully wired into the live channel: it broadcasts this device's /// The paged run flow, fully wired into the live channel: it broadcasts this device's
@@ -198,6 +223,12 @@ struct WorkoutLogListView: View {
WorkoutStatus(rawValue: log.status) ?? .notStarted WorkoutStatus(rawValue: log.status) ?? .notStarted
} }
/// Progress summary shown when ending a workout early.
private var endProgressMessage: String {
let done = doc.logs.filter { workoutStatus($0) == .completed }.count
return "\(done) of \(doc.logs.count) exercises completed."
}
// MARK: - Mutations (drive the local doc, persist via SyncEngine) // MARK: - Mutations (drive the local doc, persist via SyncEngine)
private func cycleStatus(for log: WorkoutLogDocument) { private func cycleStatus(for log: WorkoutLogDocument) {
@@ -272,27 +303,31 @@ struct WorkoutLogListView: View {
/// Recompute the workout's status/end from its logs, then persist. /// Recompute the workout's status/end from its logs, then persist.
private func save() { private func save() {
let statuses = doc.logs.map { workoutStatus($0) } doc.recomputeStatusFromLogs()
let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed }
let anyInProgress = statuses.contains { $0 == .inProgress }
let allNotStarted = statuses.allSatisfy { $0 == .notStarted }
if allCompleted {
doc.status = WorkoutStatus.completed.rawValue
doc.end = Date()
} else if anyInProgress || !allNotStarted {
doc.status = WorkoutStatus.inProgress.rawValue
doc.end = nil
} else {
doc.status = WorkoutStatus.notStarted.rawValue
doc.end = nil
}
doc.updatedAt = Date() doc.updatedAt = Date()
let snapshot = doc let snapshot = doc
Task { await sync.save(workout: snapshot) } Task { await sync.save(workout: snapshot) }
} }
/// Finish a partially-done workout: mark every not-completed exercise as skipped, so
/// the recompute resolves the workout to `.completed` (stamping `end`). The status flip
/// is what drops the run off the watch's active list and ends its HealthKit session.
private func endWorkout() {
for i in doc.logs.indices where workoutStatus(doc.logs[i]) != .completed {
doc.logs[i].status = WorkoutStatus.skipped.rawValue
doc.logs[i].completed = false
}
save()
dismiss()
}
/// Throw the workout away entirely (soft-delete via the tombstone path).
private func discardWorkout() {
let target = workout
dismiss()
Task { await sync.delete(workout: target) }
}
private func subtitleForLog(_ log: WorkoutLogDocument) -> String { private func subtitleForLog(_ log: WorkoutLogDocument) -> String {
if LoadType(rawValue: log.loadType) == .duration { if LoadType(rawValue: log.loadType) == .duration {
let mins = log.durationSeconds / 60 let mins = log.durationSeconds / 60