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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user