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
@@ -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() }
}