End the watch session on Discard, plus start-flow UX tweaks

Watch-side follow-through for the End Workout flow:
- The phone now pushes an authoritative set (in-progress, not-started, and
  completed within 24h) instead of the 25 most-recent workouts, and the watch
  prunes any workout absent from it. So a Discard/Delete (or a completed run aging
  out) drops off the watch, empties its active list, and ends the HKWorkoutSession
  — fixing the persistent wrist-raise re-foregrounding. The watch never originates
  a workout, so pruning can't lose local data; the 24h grace keeps a just-finished
  run on screen. The gate pops if the run you're viewing is pruned.

UX tweaks:
- The in-workout ⋯ is now a pull-down Menu (Add Exercise / End Workout) rather than
  an action sheet.
- Starting a split while another workout is still active now prompts to end the
  current one(s) — keeping their progress — or run in parallel. Wired into both
  start paths (the split picker and "Start This Split"), via a shared
  WorkoutDocument.endKeepingProgress() helper.

Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
This commit is contained in:
2026-06-22 21:30:06 -04:00
parent e2295aa287
commit 7400094eda
8 changed files with 205 additions and 46 deletions
+4
View File
@@ -1,5 +1,9 @@
**June 2026**
Starting a new workout while another is still going now asks whether to end the current one first or run both in parallel.
Discarding or deleting an in-progress workout on iPhone now takes your Apple Watch out of the workout too, so the watch no longer keeps waking to the workout app when you raise your wrist.
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.
+13
View File
@@ -94,6 +94,19 @@ extension WorkoutDocument {
end = nil
}
}
/// End the workout now, keeping progress: mark every not-completed log as skipped, then
/// recompute so it resolves to `.completed` (with `end` stamped). This is the
/// "End Workout Save" operation, shared by the in-workout menu and the
/// start-a-new-split prompt.
mutating func endKeepingProgress() {
for i in logs.indices where (WorkoutStatus(rawValue: logs[i].status) ?? .notStarted) != .completed {
logs[i].status = WorkoutStatus.skipped.rawValue
logs[i].completed = false
}
recomputeStatusFromLogs()
updatedAt = Date()
}
}
struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
@@ -185,15 +185,22 @@ final class WatchConnectivityBridge: NSObject {
CacheMapper.upsertSplit(s, relativePath: s.relativePath, into: context)
liveSplitIDs.insert(s.id)
}
var liveWorkoutIDs = Set<String>()
for w in workouts {
CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context)
liveWorkoutIDs.insert(w.id)
}
// Splits are sent in full prune any the phone no longer has. Workouts are
// sent as a recent window, so they're upserted but never pruned (avoids a
// race deleting a workout just created on the watch).
// Both are authoritative sets prune anything the phone no longer sends. For
// workouts that set is every active run plus recently-completed ones (~24h), so a
// run that was discarded/deleted on the phone (or aged out of the window) drops out
// of the push and is pruned here which empties the active list and ends the
// session. The watch never originates a workout, so pruning can't lose local data.
if let allSplits = try? context.fetch(FetchDescriptor<Split>()) {
for s in allSplits where !liveSplitIDs.contains(s.id) { context.delete(s) }
}
if let allWorkouts = try? context.fetch(FetchDescriptor<Workout>()) {
for w in allWorkouts where !liveWorkoutIDs.contains(w.id) { context.delete(w) }
}
try? context.save()
lastSyncDate = Date()
}
@@ -78,9 +78,11 @@ struct ActiveWorkoutGateView: View {
if noActiveWorkouts { sessionManager.end() }
}
// The phone just entered (or left) an editor if we're inside the now-locked run,
// pop back to the gate so re-entry rebuilds a fresh working copy.
.onChange(of: bridge.editingWorkoutID) { _, _ in popIfNavigatedRunLocked() }
.onChange(of: bridge.editingSplitID) { _, _ in popIfNavigatedRunLocked() }
// pop back to the gate so re-entry rebuilds a fresh working copy. Also pop if the run
// we're inside was pruned (discarded/deleted on the phone, or aged out of the push).
.onChange(of: bridge.editingWorkoutID) { _, _ in popIfNavigatedRunUnavailable() }
.onChange(of: bridge.editingSplitID) { _, _ in popIfNavigatedRunUnavailable() }
.onChange(of: workouts.map(\.id)) { _, _ in popIfNavigatedRunUnavailable() }
}
@ViewBuilder
@@ -94,10 +96,15 @@ struct ActiveWorkoutGateView: View {
}
}
/// If the run we're currently navigated into has become locked, pop to the gate.
private func popIfNavigatedRunLocked() {
guard let route = path.last,
let workout = workouts.first(where: { $0.id == route.workoutID }) else { return }
/// If the run we're currently navigated into is no longer available pruned from the
/// cache (discarded/deleted on the phone, or aged out of the pushed set), or locked
/// because the phone took over editing it pop back to the gate.
private func popIfNavigatedRunUnavailable() {
guard let route = path.last else { return }
guard let workout = workouts.first(where: { $0.id == route.workoutID }) else {
path.removeAll()
return
}
if isLockedForEditing(workout) { path.removeAll() }
}
@@ -60,9 +60,30 @@ final class PhoneConnectivityBridge: NSObject {
session.isWatchAppInstalled else { return }
let splits = (try? context.fetch(FetchDescriptor<Split>(sortBy: [SortDescriptor(\.order)]))) ?? []
var wDesc = FetchDescriptor<Workout>(sortBy: [SortDescriptor(\.start, order: .reverse)])
wDesc.fetchLimit = 25
let workouts = (try? context.fetch(wDesc)) ?? []
// The watch only needs what it can act on: every active run (in-progress /
// not-started) plus recently-completed ones, kept ~24h so a run that just
// finished still renders before the watch prunes it. The watch treats this as an
// authoritative set and prunes anything absent that's what ends its session on a
// Discard/Delete. Active runs are sent in full (no cap): there are only ever a
// handful, so "absent" unambiguously means "no longer active".
let inProgressRaw = WorkoutStatus.inProgress.rawValue
let notStartedRaw = WorkoutStatus.notStarted.rawValue
let completedRaw = WorkoutStatus.completed.rawValue
let cutoff = Date(timeIntervalSinceNow: -86_400)
let activeDesc = FetchDescriptor<Workout>(
predicate: #Predicate<Workout> { $0.statusRaw == inProgressRaw || $0.statusRaw == notStartedRaw },
sortBy: [SortDescriptor(\.start, order: .reverse)]
)
var completedDesc = FetchDescriptor<Workout>(
predicate: #Predicate<Workout> { $0.statusRaw == completedRaw },
sortBy: [SortDescriptor(\.start, order: .reverse)]
)
completedDesc.fetchLimit = 25
let active = (try? context.fetch(activeDesc)) ?? []
let recentCompleted = ((try? context.fetch(completedDesc)) ?? []).filter { ($0.end ?? $0.start) > cutoff }
let workouts = active + recentCompleted
let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45
let doneCountdownSeconds = UserDefaults.standard.object(forKey: WCPayload.doneCountdownSecondsKey) as? Int ?? 5
@@ -24,6 +24,15 @@ struct ExerciseListView: View {
@State private var pendingWorkoutID: String? = nil
@State private var resolvedWorkout: Workout? = nil
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
@State private var showingActivePrompt = false
private var activeWorkouts: [Workout] {
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
}
var body: some View {
Form {
let sortedExercises = split.exercisesArray
@@ -67,7 +76,7 @@ struct ExerciseListView: View {
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Start This Split") {
startWorkout()
confirmAndStart()
}
.disabled(split.exercisesArray.isEmpty)
}
@@ -108,10 +117,53 @@ struct ExerciseListView: View {
} message: { item in
Text("Remove \"\(item.name)\" from this split?")
}
.confirmationDialog(
activePromptTitle,
isPresented: $showingActivePrompt,
titleVisibility: .visible
) {
Button("End Current & Start New") { endActiveThenStart() }
Button("Start in Parallel") { start() }
Button("Cancel", role: .cancel) { showingActivePrompt = false }
} message: {
Text(activePromptMessage)
}
}
// MARK: - Helpers
private var activePromptTitle: String {
activeWorkouts.count == 1 ? "Workout in Progress" : "\(activeWorkouts.count) Workouts in Progress"
}
private var activePromptMessage: String {
let n = activeWorkouts.count
let those = n == 1 ? "it" : "them"
return "You already have \(n == 1 ? "a workout" : "\(n) workouts") going. End \(those) first, or run this one alongside."
}
/// Prompt before starting if other workouts are still going; otherwise start straight away.
private func confirmAndStart() {
if activeWorkouts.isEmpty {
start()
} else {
showingActivePrompt = true
}
}
/// End every in-flight workout (keeping its progress), then start this split.
private func endActiveThenStart() {
let toEnd = activeWorkouts.map { WorkoutDocument(from: $0) }
showingActivePrompt = false
Task {
for var doc in toEnd {
doc.endKeepingProgress()
await sync.save(workout: doc)
}
}
start()
}
private func pollForWorkout(id: String) {
Task {
// Give the fileobservercache loop a moment to complete (typically < 1 s).
@@ -141,8 +193,8 @@ struct ExerciseListView: View {
Task { await sync.save(split: doc) }
}
private func startWorkout() {
let start = Date()
private func start() {
let startDate = Date()
let logs = split.exercisesArray.enumerated().map { i, ex in
WorkoutLogDocument(
id: ULID.make(), exerciseName: ex.name, order: i,
@@ -150,7 +202,7 @@ struct ExerciseListView: View {
loadType: ex.loadType, durationSeconds: ex.durationTotalSeconds,
currentStateIndex: 0, completed: false,
status: WorkoutStatus.notStarted.rawValue,
notes: nil, date: start
notes: nil, date: startDate
)
}
let doc = WorkoutDocument(
@@ -158,11 +210,11 @@ struct ExerciseListView: View {
id: ULID.make(),
splitID: split.id,
splitName: split.name,
start: start,
start: startDate,
end: nil,
status: WorkoutStatus.notStarted.rawValue,
createdAt: start,
updatedAt: start,
createdAt: startDate,
updatedAt: startDate,
logs: logs
)
Task {
@@ -26,7 +26,6 @@ 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?
@@ -132,8 +131,19 @@ struct WorkoutLogListView: View {
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button {
showingActions = true
showingAddSheet = true
} label: {
Label("Add Exercise", systemImage: "plus")
}
if !sortedLogs.isEmpty {
Button {
showingEndOptions = true
} label: {
Label("End Workout", systemImage: "flag.checkered")
}
}
} label: {
Image(systemName: "ellipsis.circle")
}
@@ -164,17 +174,6 @@ 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,
@@ -313,11 +312,9 @@ struct WorkoutLogListView: View {
/// 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()
doc.endKeepingProgress()
let snapshot = doc
Task { await sync.save(workout: snapshot) }
dismiss()
}
@@ -114,12 +114,23 @@ struct SplitPickerSheet: View {
@Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)])
private var splits: [Split]
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
/// Set when the user picks a split while other workouts are still going drives the
/// "end the current one(s) or run in parallel?" prompt.
@State private var splitAwaitingConfirmation: Split?
private var activeWorkouts: [Workout] {
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
}
var body: some View {
NavigationStack {
List {
ForEach(splits) { split in
Button {
startWorkout(with: split)
confirmAndStart(with: split)
} label: {
HStack {
Image(systemName: split.systemImage)
@@ -141,11 +152,58 @@ struct SplitPickerSheet: View {
}
}
}
.confirmationDialog(
activePromptTitle,
isPresented: Binding(
get: { splitAwaitingConfirmation != nil },
set: { if !$0 { splitAwaitingConfirmation = nil } }
),
titleVisibility: .visible,
presenting: splitAwaitingConfirmation
) { split in
Button("End Current & Start New") { endActiveThenStart(with: split) }
Button("Start in Parallel") { start(with: split) }
Button("Cancel", role: .cancel) { splitAwaitingConfirmation = nil }
} message: { _ in
Text(activePromptMessage)
}
}
}
private func startWorkout(with split: Split) {
let start = Date()
private var activePromptTitle: String {
activeWorkouts.count == 1 ? "Workout in Progress" : "\(activeWorkouts.count) Workouts in Progress"
}
private var activePromptMessage: String {
let n = activeWorkouts.count
let those = n == 1 ? "it" : "them"
return "You already have \(n == 1 ? "a workout" : "\(n) workouts") going. End \(those) first, or run this one alongside."
}
/// Prompt before starting if other workouts are still going; otherwise start straight away.
private func confirmAndStart(with split: Split) {
if activeWorkouts.isEmpty {
start(with: split)
} else {
splitAwaitingConfirmation = split
}
}
/// End every in-flight workout (keeping its progress), then start the picked split.
private func endActiveThenStart(with split: Split) {
let toEnd = activeWorkouts.map { WorkoutDocument(from: $0) }
splitAwaitingConfirmation = nil
Task {
for var doc in toEnd {
doc.endKeepingProgress()
await sync.save(workout: doc)
}
}
start(with: split)
}
private func start(with split: Split) {
let startDate = Date()
let logs = split.exercisesArray.enumerated().map { index, exercise in
WorkoutLogDocument(
id: ULID.make(),
@@ -160,7 +218,7 @@ struct SplitPickerSheet: View {
completed: false,
status: WorkoutStatus.notStarted.rawValue,
notes: nil,
date: start
date: startDate
)
}
@@ -170,11 +228,11 @@ struct SplitPickerSheet: View {
id: ULID.make(),
splitID: split.id,
splitName: split.name,
start: start,
start: startDate,
end: nil,
status: WorkoutStatus.notStarted.rawValue,
createdAt: start,
updatedAt: start,
createdAt: startDate,
updatedAt: startDate,
logs: logs
)