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
@@ -523,21 +523,7 @@ struct ExerciseProgressView: View {
}
private func recomputeWorkoutStatus() {
let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted }
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.recomputeStatusFromLogs()
}
// MARK: - Formatting
+1 -15
View File
@@ -211,21 +211,7 @@ struct ExerciseView: View {
/// Recompute the workout's status/end from its logs.
private func recomputeWorkoutStatus() {
let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted }
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.recomputeStatusFromLogs()
}
/// 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(LiveRunState.self) private var liveRun
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
let workout: Workout
@@ -25,6 +26,8 @@ struct WorkoutLogListView: View {
@State private var doc: WorkoutDocument
@State private var showingAddSheet = false
@State private var showingActions = false
@State private var showingEndOptions = false
@State private var logToDelete: WorkoutLogDocument?
@State private var addedLog: LogRoute?
@State private var logToEdit: LogRoute?
@@ -130,9 +133,9 @@ struct WorkoutLogListView: View {
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingAddSheet.toggle()
showingActions = true
} label: {
Image(systemName: "plus")
Image(systemName: "ellipsis.circle")
}
}
}
@@ -161,6 +164,28 @@ struct WorkoutLogListView: View {
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
@@ -198,6 +223,12 @@ struct WorkoutLogListView: View {
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)
private func cycleStatus(for log: WorkoutLogDocument) {
@@ -272,27 +303,31 @@ struct WorkoutLogListView: View {
/// Recompute the workout's status/end from its logs, then persist.
private func save() {
let statuses = doc.logs.map { workoutStatus($0) }
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.recomputeStatusFromLogs()
doc.updatedAt = Date()
let snapshot = doc
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 {
if LoadType(rawValue: log.loadType) == .duration {
let mins = log.durationSeconds / 60