a16e8ec270
Add an ephemeral live-run presence channel (separate from the durable iCloud progress sync) so a propped-up iPhone can mirror the Watch's Ready → work/rest → Finish flow in real time as the user swipes. Watch drives, phone mirrors (read-only), so there's no echo loop: - Watch's ExerciseProgressView broadcasts a LiveProgress frame on every phase transition (and an ended signal on leave) via sendMessage, reachable-only — throwaway presence, never written to iCloud. - Timers ride as wall-clock anchors (Date kept native in the WC dict to preserve sub-second precision), so both devices count independently off shared start times and stay in lockstep without streaming ticks. - Phone holds a transient LiveRunState; ContentView auto-presents a read-only LiveProgressMirrorView full-screen cover while a run is live. Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
153 lines
6.7 KiB
Swift
153 lines
6.7 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, so the phone mirror can drop a
|
|
/// stale / out-of-order delivery.
|
|
private var liveVersion = 0
|
|
|
|
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; reachable-only)
|
|
|
|
/// Broadcast where the run flow currently is, so a propped-up iPhone can mirror it. Sent
|
|
/// over `sendMessage` only when the phone is reachable — this is throwaway presence, so
|
|
/// there's no guaranteed-delivery fallback (a queued frame would be stale on arrival).
|
|
func sendLiveProgress(_ frame: LiveProgress) {
|
|
guard let session, session.activationState == .activated, session.isReachable else { return }
|
|
liveVersion += 1
|
|
var stamped = frame
|
|
stamped.version = liveVersion
|
|
session.sendMessage(WCPayload.encodeLiveProgress(stamped), replyHandler: nil, errorHandler: { _ in })
|
|
}
|
|
|
|
/// Tell the phone to stop mirroring this run (the user left the progress flow).
|
|
func sendLiveEnded(workoutID: String, logID: String) {
|
|
guard let session, session.activationState == .activated, session.isReachable else { return }
|
|
session.sendMessage(WCPayload.encodeLiveEnded(workoutID: workoutID, logID: logID),
|
|
replyHandler: nil, errorHandler: { _ in })
|
|
}
|
|
|
|
// MARK: - Internal
|
|
|
|
private func sendToPhone(_ doc: WorkoutDocument) {
|
|
guard let session, session.activationState == .activated else { return }
|
|
let payload = WCPayload.encodeWorkoutUpdate(doc)
|
|
if session.isReachable {
|
|
session.sendMessage(payload, replyHandler: nil, errorHandler: { _ in
|
|
session.transferUserInfo(payload) // fall back to guaranteed delivery
|
|
})
|
|
} 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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
for w in workouts {
|
|
CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context)
|
|
}
|
|
// 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).
|
|
if let allSplits = try? context.fetch(FetchDescriptor<Split>()) {
|
|
for s in allSplits where !liveSplitIDs.contains(s.id) { context.delete(s) }
|
|
}
|
|
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() }
|
|
}
|
|
|
|
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 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) }
|
|
// Absent keys mean "not editing" — set unconditionally so the lock clears.
|
|
self.editingWorkoutID = editingWorkoutID
|
|
self.editingSplitID = editingSplitID
|
|
}
|
|
}
|
|
}
|