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.
134 lines
6.2 KiB
Swift
134 lines
6.2 KiB
Swift
import Foundation
|
|
|
|
/// Wire format for the iPhone↔Watch bridge. The phone is the only device that
|
|
/// touches iCloud Drive; the watch round-trips domain documents through the phone.
|
|
/// Payloads carry the shared `*Document` types (JSON-encoded `Data` blobs, which
|
|
/// WatchConnectivity allows) keyed by stable ULIDs — no name/date reconciliation.
|
|
enum WCPayload {
|
|
static let typeKey = "type"
|
|
static let splitsKey = "splits"
|
|
static let workoutsKey = "workouts"
|
|
static let workoutKey = "workout"
|
|
static let restSecondsKey = "restSeconds"
|
|
static let doneCountdownSecondsKey = "doneCountdownSeconds"
|
|
static let weightUnitKey = "weightUnit"
|
|
static let editingWorkoutIDKey = "editingWorkoutID"
|
|
static let editingSplitIDKey = "editingSplitID"
|
|
|
|
static let workoutUpdateType = "workoutUpdate" // watch → phone (one workout)
|
|
static let requestSyncType = "requestSync" // watch → phone (please push state)
|
|
static let liveProgressType = "liveProgress" // watch → phone (ephemeral mirror frame)
|
|
static let liveEndedType = "liveEnded" // watch → phone (stop mirroring a run)
|
|
|
|
// MARK: - Phone → Watch (application context: latest-state-wins)
|
|
|
|
/// `editingWorkoutID` / `editingSplitID` are an exclusive-edit lock: while the phone
|
|
/// has a workout's exercise (or a split) open in an editor, the watch parks any
|
|
/// matching run and locks re-entry, so only one device owns the run at a time. They're
|
|
/// part of the same latest-wins context — absent keys mean "not editing" (lock clear).
|
|
static func encodeState(splits: [SplitDocument], workouts: [WorkoutDocument], restSeconds: Int, doneCountdownSeconds: Int, weightUnit: String, editingWorkoutID: String?, editingSplitID: String?) -> [String: Any] {
|
|
var dict: [String: Any] = [:]
|
|
if let s = try? DocumentCoder.encoder.encode(splits) { dict[splitsKey] = s }
|
|
if let w = try? DocumentCoder.encoder.encode(workouts) { dict[workoutsKey] = w }
|
|
dict[restSecondsKey] = restSeconds
|
|
dict[doneCountdownSecondsKey] = doneCountdownSeconds
|
|
dict[weightUnitKey] = weightUnit
|
|
if let editingWorkoutID { dict[editingWorkoutIDKey] = editingWorkoutID }
|
|
if let editingSplitID { dict[editingSplitIDKey] = editingSplitID }
|
|
return dict
|
|
}
|
|
|
|
static func decodeSplits(_ dict: [String: Any]) -> [SplitDocument] {
|
|
guard let data = dict[splitsKey] as? Data else { return [] }
|
|
return (try? DocumentCoder.decoder.decode([SplitDocument].self, from: data)) ?? []
|
|
}
|
|
|
|
static func decodeWorkouts(_ dict: [String: Any]) -> [WorkoutDocument] {
|
|
guard let data = dict[workoutsKey] as? Data else { return [] }
|
|
return (try? DocumentCoder.decoder.decode([WorkoutDocument].self, from: data)) ?? []
|
|
}
|
|
|
|
static func decodeRestSeconds(_ dict: [String: Any]) -> Int? { dict[restSecondsKey] as? Int }
|
|
|
|
static func decodeDoneCountdownSeconds(_ dict: [String: Any]) -> Int? { dict[doneCountdownSecondsKey] as? Int }
|
|
|
|
static func decodeWeightUnit(_ dict: [String: Any]) -> String? { dict[weightUnitKey] as? String }
|
|
|
|
static func decodeEditingWorkoutID(_ dict: [String: Any]) -> String? { dict[editingWorkoutIDKey] as? String }
|
|
|
|
static func decodeEditingSplitID(_ dict: [String: Any]) -> String? { dict[editingSplitIDKey] as? String }
|
|
|
|
// MARK: - Watch → Phone (a single updated workout)
|
|
|
|
static func encodeWorkoutUpdate(_ workout: WorkoutDocument) -> [String: Any] {
|
|
var dict: [String: Any] = [typeKey: workoutUpdateType]
|
|
if let w = try? DocumentCoder.encoder.encode(workout) { dict[workoutKey] = w }
|
|
return dict
|
|
}
|
|
|
|
static func decodeWorkoutUpdate(_ dict: [String: Any]) -> WorkoutDocument? {
|
|
guard let data = dict[workoutKey] as? Data else { return nil }
|
|
return try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data)
|
|
}
|
|
|
|
static func requestSyncMessage() -> [String: Any] { [typeKey: requestSyncType] }
|
|
|
|
// MARK: - Watch → Phone (ephemeral live-run mirror)
|
|
|
|
/// Anchor `Date`s ride as native plist values (not JSON), so they keep sub-second
|
|
/// precision — `DocumentCoder` is `.iso8601`, which would round the timer off.
|
|
static let lpWorkoutIDKey = "lpWorkoutID"
|
|
static let lpLogIDKey = "lpLogID"
|
|
static let lpNameKey = "lpName"
|
|
static let lpPhaseKey = "lpPhase"
|
|
static let lpSetIndexKey = "lpSetIndex"
|
|
static let lpSetCountKey = "lpSetCount"
|
|
static let lpDetailKey = "lpDetail"
|
|
static let lpStartKey = "lpStart"
|
|
static let lpEndKey = "lpEnd"
|
|
static let lpVersionKey = "lpVersion"
|
|
|
|
static func encodeLiveProgress(_ p: LiveProgress) -> [String: Any] {
|
|
var dict: [String: Any] = [typeKey: liveProgressType]
|
|
dict[lpWorkoutIDKey] = p.workoutID
|
|
dict[lpLogIDKey] = p.logID
|
|
dict[lpNameKey] = p.exerciseName
|
|
dict[lpPhaseKey] = p.phase.rawValue
|
|
dict[lpSetIndexKey] = p.setIndex
|
|
dict[lpSetCountKey] = p.setCount
|
|
dict[lpDetailKey] = p.detail
|
|
dict[lpStartKey] = p.phaseStart
|
|
if let end = p.phaseEnd { dict[lpEndKey] = end }
|
|
dict[lpVersionKey] = p.version
|
|
return dict
|
|
}
|
|
|
|
static func decodeLiveProgress(_ dict: [String: Any]) -> LiveProgress? {
|
|
guard let workoutID = dict[lpWorkoutIDKey] as? String,
|
|
let logID = dict[lpLogIDKey] as? String,
|
|
let phaseRaw = dict[lpPhaseKey] as? String,
|
|
let phase = LiveRunPhase(rawValue: phaseRaw),
|
|
let setIndex = dict[lpSetIndexKey] as? Int,
|
|
let setCount = dict[lpSetCountKey] as? Int,
|
|
let start = dict[lpStartKey] as? Date,
|
|
let version = dict[lpVersionKey] as? Int
|
|
else { return nil }
|
|
return LiveProgress(
|
|
workoutID: workoutID,
|
|
logID: logID,
|
|
exerciseName: dict[lpNameKey] as? String ?? "",
|
|
phase: phase,
|
|
setIndex: setIndex,
|
|
setCount: setCount,
|
|
detail: dict[lpDetailKey] as? String ?? "",
|
|
phaseStart: start,
|
|
phaseEnd: dict[lpEndKey] as? Date,
|
|
version: version
|
|
)
|
|
}
|
|
|
|
static func encodeLiveEnded(workoutID: String, logID: String) -> [String: Any] {
|
|
[typeKey: liveEndedType, lpWorkoutIDKey: workoutID, lpLogIDKey: logID]
|
|
}
|
|
}
|