Files
workouts/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift
T
rzen 0ad93a09da Save workouts to Apple Health with live watch metrics and a post-workout summary
Watch sessions now run an HKLiveWorkoutBuilder: heart rate and active
energy are collected live (shown in an in-workout HUD), saved to Health
as a real HKWorkout on completion, and carried back to the phone as
WorkoutMetrics on the workout document. Phone-only workouts get an
estimated Health workout (MET x bodyweight x duration) after a 10s
debounce that a late-arriving watch session cancels; a launch sweep
backfills workouts completed within the last day. Dedupe is keyed on
metrics.healthKitWorkoutUUID plus the metrics source.

Splits gain an activity type (strength, functional, HIIT, core, cardio,
cycling) that categorizes the Health workout and picks the MET value.
A post-workout summary sheet (duration, calories, avg/max HR, HR zones,
total volume) fills in live and is also shown on completed workouts.
Weights can now display in lb or kg (display-only relabel), synced to
the watch over the existing application context.

WorkoutDocument schema 1->2 (metrics are irreplaceable, so older apps
must quarantine rather than strip them); cache schema 2 rebuilds the
SwiftData store with the new metric columns. Deleting a workout in the
app intentionally leaves its Health record in place.
2026-07-05 07:46:35 -04:00

271 lines
13 KiB
Swift

import Foundation
import Observation
import SwiftData
import WatchConnectivity
/// Watch side of the iPhone↔Watch bridge. The watch never touches iCloud — it
/// keeps a local SwiftData cache fed only by application-context pushes from the
/// phone, updates it optimistically on local edits, and forwards changed workouts
/// to the phone (which is the sole writer of iCloud Drive).
@Observable
@MainActor
final class WatchConnectivityBridge: NSObject {
private let container: ModelContainer
private var session: WCSession?
/// Last time state was received from the phone (for a sync indicator).
private(set) var lastSyncDate: Date?
/// Exclusive-edit lock pushed by the phone. While set, the watch parks the matching
/// run (popping out of its progress view) and blocks re-entry, so the phone owns the
/// edit and the watch can't clobber it with a stale optimistic write. `editingWorkoutID`
/// matches a run by its workout id; `editingSplitID` matches any run by its `splitID`.
private(set) var editingWorkoutID: String?
private(set) var editingSplitID: String?
/// Monotonic sequence stamped on each live-run frame we send. Bumped to stay ahead of any
/// frame we *receive*, so the two devices share one increasing per-run sequence and either
/// side can drop a stale / out-of-order delivery (see `LiveProgress.version`).
private var liveVersion = 0
/// The latest live-run message we haven't confirmed reached the phone (depth 1, latest-wins).
/// Staged regardless of reachability and re-sent by `flushLive()` when reachability/activation
/// returns, so a brief WatchConnectivity drop doesn't desync the mirror. A newer frame — or the
/// terminal `.ended` — replaces it, so we never deliver stale state. Frames carry an absolute
/// wall-clock anchor, so a late re-send self-corrects on arrival rather than reading as stale.
private var pendingLive: PendingLive?
/// The latest live-run frame the *phone* sent, for the run we currently have open to apply
/// (ephemeral; nil when the phone isn't driving). The watch's `ExerciseProgressView` reads
/// this to follow a phone-driven transition; it's never persisted.
private(set) var liveIncoming: LiveProgress?
/// The run currently open in the watch's navigated driver. When the incoming frame is for
/// it, the watch follows inline there and suppresses the follower cover (so it never stacks
/// on top of a run the user already has open).
var navigatedRunID: String?
/// A run the user dismissed the follower cover for; suppressed until that run ends.
private var mutedLogID: String?
/// The frame to present as a follower cover when the phone drives a run the watch isn't
/// already showing: the latest, unless the user dismissed it or has that run open inline.
var presentable: LiveProgress? {
guard let f = liveIncoming, f.logID != mutedLogID, f.logID != navigatedRunID else { return nil }
return f
}
/// The user dismissed the follower cover; don't re-present this run until it ends.
func muteLive() { mutedLogID = liveIncoming?.logID }
private var context: ModelContext { container.mainContext }
init(container: ModelContainer) {
self.container = container
super.init()
}
func activate() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
session.activate()
self.session = session
// Apply whatever the phone last pushed, then ask for a fresh push.
let ctx = session.receivedApplicationContext
applyState(WCPayload.decodeSplits(ctx), workouts: WCPayload.decodeWorkouts(ctx))
applySettings(ctx)
editingWorkoutID = WCPayload.decodeEditingWorkoutID(ctx)
editingSplitID = WCPayload.decodeEditingSplitID(ctx)
requestSync()
}
func requestSync() {
guard let session, session.activationState == .activated, session.isReachable else { return }
session.sendMessage(WCPayload.requestSyncMessage(), replyHandler: nil, errorHandler: nil)
}
/// Optimistically applies a workout edit to the local cache and forwards it to
/// the phone for durable persistence in iCloud Drive.
func update(workout doc: WorkoutDocument) {
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
try? context.save()
sendToPhone(doc)
}
// MARK: - Live run mirror (ephemeral; coalesced redelivery)
/// Broadcast where the run flow currently is, so a propped-up iPhone can mirror it. Staged as
/// the latest pending frame and sent when the phone is reachable; if it's unreachable the frame
/// is held and re-sent on reconnect (`flushLive`). Because frames are full state snapshots with
/// a wall-clock anchor, holding only the newest one (depth 1) and self-correcting its timers on
/// arrival means a re-send is never stale.
func sendLiveProgress(_ frame: LiveProgress) {
guard let session, session.activationState == .activated else { return }
liveVersion += 1
var stamped = frame
stamped.version = liveVersion
pendingLive = .progress(stamped)
flushLive()
}
/// Tell the phone to stop mirroring this run (the user left the progress flow). Staged like a
/// frame so a drop at the moment the run ends doesn't strand the phone's follower cover — the
/// terminal marker supersedes any pending progress and is re-sent on reconnect.
func sendLiveEnded(workoutID: String, logID: String) {
guard let session, session.activationState == .activated else { return }
pendingLive = .ended(workoutID: workoutID, logID: logID)
flushLive()
}
/// (Re)send the staged live-run message if the phone is reachable. Called on each new frame and
/// whenever reachability/activation is restored. Leaves it staged on failure; a newer frame or
/// `.ended` supersedes it, so we never deliver stale state. The error handler runs on
/// WatchConnectivity's background queue, so it must be nonisolated (@Sendable) — an
/// inherited-@MainActor closure would trap (swift_task_checkIsolated) there.
private func flushLive() {
guard let session, let pending = pendingLive,
session.activationState == .activated, session.isReachable else { return }
let payload: [String: Any]
switch pending {
case .progress(let frame):
payload = WCPayload.encodeLiveProgress(frame)
case .ended(let workoutID, let logID):
payload = WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID)
}
session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable _ in })
}
/// Apply a live-run frame the phone sent. Drops a stale one for the same run, and catches
/// our send counter up so the next frame we send outranks it (shared per-run sequence).
private func applyIncomingLive(_ frame: LiveProgress) {
liveVersion = max(liveVersion, frame.version)
if let current = liveIncoming, current.logID == frame.logID, frame.version < current.version { return }
liveIncoming = frame
}
/// The phone left the run — stop following it (and clear any dismiss for it).
private func endIncomingLive(logID: String) {
if liveIncoming?.logID == logID { liveIncoming = nil }
if mutedLogID == logID { mutedLogID = nil }
}
// MARK: - Internal
private func sendToPhone(_ doc: WorkoutDocument) {
guard let session, session.activationState == .activated else { return }
let payload = WCPayload.encodeWorkoutUpdate(doc)
if session.isReachable {
// The error handler runs on WatchConnectivity's background queue, so it must be
// nonisolated (@Sendable) or it would trap (swift_task_checkIsolated). Hop back to the
// MainActor to fall back to guaranteed delivery; `doc` is Sendable, the payload isn't.
session.sendMessage(payload, replyHandler: nil, errorHandler: { @Sendable _ in
Task { @MainActor in
_ = self.session?.transferUserInfo(WCPayload.encodeWorkoutUpdate(doc))
}
})
} else {
session.transferUserInfo(payload)
}
}
private func applySettings(_ dict: [String: Any]) {
if let rest = WCPayload.decodeRestSeconds(dict) {
UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey)
}
if let done = WCPayload.decodeDoneCountdownSeconds(dict) {
UserDefaults.standard.set(done, forKey: WCPayload.doneCountdownSecondsKey)
}
if let unit = WCPayload.decodeWeightUnit(dict) {
UserDefaults.standard.set(unit, forKey: WCPayload.weightUnitKey)
}
}
private func applyState(_ splits: [SplitDocument], workouts: [WorkoutDocument]) {
guard !splits.isEmpty || !workouts.isEmpty else { return }
var liveSplitIDs = Set<String>()
for s in splits {
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)
}
// 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()
}
}
// MARK: - WCSessionDelegate
extension WatchConnectivityBridge: WCSessionDelegate {
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
Task { @MainActor in
self.requestSync()
self.flushLive() // deliver any frame staged before the session was ready
}
}
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
if session.isReachable {
Task { @MainActor in self.flushLive() } // catch the phone up on the latest run state after a reconnect
}
}
/// Live-run frames arrive as messages (reachable-only), distinct from the latest-wins
/// application context that carries durable state.
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
switch message[WCPayload.typeKey] as? String {
case WCPayload.liveProgressType:
if let frame = WCPayload.decodeLiveProgress(message) {
Task { @MainActor in self.applyIncomingLive(frame) }
}
case WCPayload.liveEndedType:
if let logID = message[WCPayload.lpLogIDKey] as? String {
Task { @MainActor in self.endIncomingLive(logID: logID) }
}
default:
break
}
}
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
let splits = WCPayload.decodeSplits(applicationContext)
let workouts = WCPayload.decodeWorkouts(applicationContext)
let rest = WCPayload.decodeRestSeconds(applicationContext)
let done = WCPayload.decodeDoneCountdownSeconds(applicationContext)
let unit = WCPayload.decodeWeightUnit(applicationContext)
let editingWorkoutID = WCPayload.decodeEditingWorkoutID(applicationContext)
let editingSplitID = WCPayload.decodeEditingSplitID(applicationContext)
Task { @MainActor in
self.applyState(splits, workouts: workouts)
if let rest { UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey) }
if let done { UserDefaults.standard.set(done, forKey: WCPayload.doneCountdownSecondsKey) }
if let unit { UserDefaults.standard.set(unit, forKey: WCPayload.weightUnitKey) }
// Absent keys mean "not editing" — set unconditionally so the lock clears.
self.editingWorkoutID = editingWorkoutID
self.editingSplitID = editingSplitID
}
}
}
/// The single staged live-run message awaiting (re)delivery to the phone — see `pendingLive`.
/// One slot, latest-wins: a newer progress frame or the terminal `.ended` replaces whatever's held.
private enum PendingLive {
case progress(LiveProgress)
case ended(workoutID: String, logID: String)
}