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
@@ -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