Save workouts to Apple Health with live watch metrics and a post-workout summary
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.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// HealthKitMapping.swift
|
||||
// Workouts (Shared)
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import HealthKit
|
||||
|
||||
// Bridges the app's HealthKit-free domain types to HealthKit, and centralizes the
|
||||
// authorization type sets so the watch (which records the session) and the phone
|
||||
// (which writes an estimated workout when no watch ran) request consistent scopes.
|
||||
|
||||
extension WorkoutActivityType {
|
||||
/// The HealthKit activity type used to tag a saved workout, so it lands in the
|
||||
/// right Apple Health / Fitness category and credits the rings appropriately.
|
||||
var hkActivityType: HKWorkoutActivityType {
|
||||
switch self {
|
||||
case .traditionalStrength: .traditionalStrengthTraining
|
||||
case .functionalStrength: .functionalStrengthTraining
|
||||
case .hiit: .highIntensityIntervalTraining
|
||||
case .coreTraining: .coreTraining
|
||||
case .cardio: .mixedCardio
|
||||
case .cycling: .cycling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Σ sets×reps×weight across weighted logs — the app-native "how much work" number.
|
||||
/// Computed in the stored weight unit (display-only units never change the value).
|
||||
enum WorkoutVolume {
|
||||
static func total(_ logs: [WorkoutLogDocument]) -> Double {
|
||||
logs.reduce(0) { sum, log in
|
||||
guard LoadType(rawValue: log.loadType) == .weight else { return sum }
|
||||
return sum + Double(log.sets * log.reps * log.weight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The HealthKit scopes each side requests. Kept here so the share/read sets stay in
|
||||
/// sync with what the writers actually use.
|
||||
enum WorkoutHealthAuthorization {
|
||||
/// Watch: records a live session (writes the workout + energy, reads HR/energy back).
|
||||
static let watchShare: Set<HKSampleType> = [
|
||||
.workoutType(),
|
||||
HKQuantityType(.activeEnergyBurned),
|
||||
]
|
||||
static let watchRead: Set<HKObjectType> = [
|
||||
HKQuantityType(.heartRate),
|
||||
HKQuantityType(.activeEnergyBurned),
|
||||
HKCharacteristicType(.dateOfBirth), // → max HR for zone tracking
|
||||
]
|
||||
|
||||
/// Phone: writes an estimated workout when no watch session ran; reads body
|
||||
/// measurements to size the calorie estimate (and HR to display watch data).
|
||||
static let phoneShare: Set<HKSampleType> = [
|
||||
.workoutType(),
|
||||
HKQuantityType(.activeEnergyBurned),
|
||||
]
|
||||
static let phoneRead: Set<HKObjectType> = [
|
||||
HKQuantityType(.bodyMass),
|
||||
HKQuantityType(.heartRate),
|
||||
HKCharacteristicType(.dateOfBirth),
|
||||
HKCharacteristicType(.biologicalSex),
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user