diff --git a/CHANGELOG.md b/CHANGELOG.md index b9fd82a..05d807e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **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. 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. diff --git a/README.md b/README.md index 46f5212..af09541 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ your own iCloud Drive. - **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 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 sessions. - **Apple Watch companion** — starting a workout on the iPhone launches the watch diff --git a/Shared/Model/Documents.swift b/Shared/Model/Documents.swift index 8e44993..bd28084 100644 --- a/Shared/Model/Documents.swift +++ b/Shared/Model/Documents.swift @@ -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 { var id: String // ULID var exerciseName: String diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index c85e4d4..48586f4 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -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 diff --git a/Workouts/Views/WorkoutLogs/ExerciseView.swift b/Workouts/Views/WorkoutLogs/ExerciseView.swift index ee53136..6b52bce 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseView.swift @@ -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 diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index 592f9ec..cd7ba72 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -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