Files
workouts/Shared/Connectivity/WCPayload.swift
T
rzen aa23d7baa7 Add a live status panel between the run screen's halves
Replaces the small target-HR pill on the iPhone run screen with a
glanceable big-digit strip between the timer flow and the figure —
heart rate (band-tinted with the cue arrow when the run carries a
target), HR zone, active calories, and the workout stopwatch. A
horizontal band in portrait, a vertical column in landscape (the
inverse of the half-and-half split, so it always sits between them).

The watch now rides the running calorie total and the HR zone (1-5,
computed watch-side where max HR is known) along with each live HR
sample; absent keys keep older builds wire-compatible both ways.
LiveRunState holds all three under the shared staleness expiry. The
screenshot rig seeds believable values so the run capture shows the
panel populated.
2026-07-16 18:54:39 -04:00

170 lines
8.4 KiB
Swift

import IndieSync
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"
// PINNED VALUES: the constant names became `routinesKey` / `editingRoutineIDKey`,
// but their string VALUES stay `"splits"` / `"editingSplitID"` so an updated phone
// still interops with a not-yet-updated watch over WatchConnectivity.
static let routinesKey = "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 editingRoutineIDKey = "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)
static let liveHeartRateType = "liveHeartRate" // watch → phone (ephemeral HR sample)
// MARK: - Phone → Watch (application context: latest-state-wins)
/// `editingWorkoutID` / `editingRoutineID` are an exclusive-edit lock: while the phone
/// has a workout's exercise (or a routine) 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(routines: [RoutineDocument], workouts: [WorkoutDocument], restSeconds: Int, doneCountdownSeconds: Int, weightUnit: String, editingWorkoutID: String?, editingRoutineID: String?) -> [String: Any] {
var dict: [String: Any] = [:]
if let s = try? DocumentCoder.encoder.encode(routines) { dict[routinesKey] = 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 editingRoutineID { dict[editingRoutineIDKey] = editingRoutineID }
return dict
}
/// `nil` means the payload carried routines that failed to decode (a schema mismatch
/// between the two builds) — distinct from an absent key or a legitimately empty
/// list, so the receiver can surface it instead of silently applying nothing.
static func decodeRoutines(_ dict: [String: Any]) -> [RoutineDocument]? {
guard let data = dict[routinesKey] as? Data else { return [] }
return try? DocumentCoder.decoder.decode([RoutineDocument].self, from: data)
}
/// See `decodeRoutines` — `nil` is a decode failure, not an empty list.
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 decodeEditingRoutineID(_ dict: [String: Any]) -> String? { dict[editingRoutineIDKey] 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]
}
// MARK: - Watch → Phone (ephemeral live heart rate)
// Deliberately outside the `LiveProgress` machinery: HR is a device-level gauge
// (the wearer's pulse, not a run position), updates far more often than phase
// transitions, and must never bump the frame version sequence or re-anchor
// timers. Best-effort, latest-wins, display-only — a dropped sample just means
// the readout refreshes on the next one.
static let hrBpmKey = "hrBpm"
static let hrAtKey = "hrAt"
static let hrKcalKey = "hrKcal"
static let hrZoneKey = "hrZone"
/// The session's running calories and HR zone ride along with each sample when
/// known — absent keys (an older watch, or no energy sample yet) decode as nil,
/// so the two builds stay wire-compatible in both directions.
static func encodeLiveHeartRate(bpm: Double, at date: Date, kcal: Double? = nil, zone: Int? = nil) -> [String: Any] {
var dict: [String: Any] = [typeKey: liveHeartRateType, hrBpmKey: bpm, hrAtKey: date]
if let kcal { dict[hrKcalKey] = kcal }
if let zone { dict[hrZoneKey] = zone }
return dict
}
static func decodeLiveHeartRate(_ dict: [String: Any]) -> (bpm: Double, at: Date, kcal: Double?, zone: Int?)? {
guard let bpm = dict[hrBpmKey] as? Double, let at = dict[hrAtKey] as? Date else { return nil }
return (bpm, at, dict[hrKcalKey] as? Double, dict[hrZoneKey] as? Int)
}
}