d5915a9552
- Redesign the watch app into an active-workout runner: a root gate shows the in-progress workout's exercises or prompts to start one on iPhone, and each exercise runs as a horizontally-paged HIIT cycle (count-up work, count-down rest with final-three-second haptics + auto-advance, One More / Done on the last set). Replaces the old history list. - Add a configurable rest-between-sets duration in iPhone Settings (default 45s), synced to the watch over WatchConnectivity. - Launch the watch app into the session when a workout starts on the phone via HealthKit (startWatchApp); the watch runs an HKWorkoutSession for foreground runtime and ends it when the workout finishes. Adds the HealthKit entitlement + Health usage strings on both targets and WKBackgroundModes on the watch. Claude-Session: https://claude.ai/code/session_018gg69MaUetDNzWzBXisfMV
110 lines
4.3 KiB
Swift
110 lines
4.3 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?
|
|
|
|
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.
|
|
applyState(WCPayload.decodeSplits(session.receivedApplicationContext),
|
|
workouts: WCPayload.decodeWorkouts(session.receivedApplicationContext))
|
|
applyRestSeconds(session.receivedApplicationContext)
|
|
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: - 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 applyRestSeconds(_ dict: [String: Any]) {
|
|
if let rest = WCPayload.decodeRestSeconds(dict) {
|
|
UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey)
|
|
}
|
|
}
|
|
|
|
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)
|
|
Task { @MainActor in
|
|
self.applyState(splits, workouts: workouts)
|
|
if let rest { UserDefaults.standard.set(rest, forKey: WCPayload.restSecondsKey) }
|
|
}
|
|
}
|
|
}
|