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.
170 lines
8.4 KiB
Swift
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)
|
|
}
|
|
}
|