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