feat(watch): add workout summary screen with HealthKit data

This commit is contained in:
2026-08-08 12:05:32 +02:00
parent 831ec6b836
commit f3b3a8b944
5 changed files with 307 additions and 6 deletions
+123 -3
View File
@@ -15,8 +15,10 @@ class WorkoutManager: ObservableObject {
@Published var currentLapDistance = 0
@Published var elapsedSeconds: TimeInterval = 0
@Published var heartRate: Double = 0
@Published var activeCalories: Double = 0
@Published var isResting = false
@Published var restTimeRemaining: TimeInterval = 0
@Published var workoutSummary: WorkoutSummary?
private var workoutTimer: Timer?
private var restTimer: Timer?
@@ -26,6 +28,10 @@ class WorkoutManager: ObservableObject {
private var lapStartTime: Date?
private var completedLaps: [CompletedLap] = []
// Heart rate & calorie queries
private var heartRateQuery: HKObserverQuery?
private var calorieQuery: HKObserverQuery?
// Lap detection
private let motionManager = CMMotionManager()
private var lapDetectionTimer: Timer?
@@ -91,6 +97,10 @@ class WorkoutManager: ObservableObject {
selectedWorkout = workout
}
func dismissSummary() {
workoutSummary = nil
}
// MARK: - Workout Control
func startWorkout() {
@@ -100,11 +110,15 @@ class WorkoutManager: ObservableObject {
currentSetIndex = 0
currentRep = 0
elapsedSeconds = 0
activeCalories = 0
heartRate = 0
completedLaps = []
wasStationary = false
stationarySince = nil
startHealthKitWorkout()
startHeartRateQuery()
startCalorieQuery()
startTimer()
startLapDetection()
WKInterfaceDevice.current().enableWaterLock()
@@ -114,12 +128,16 @@ class WorkoutManager: ObservableObject {
func pauseWorkout() {
workoutTimer?.invalidate()
stopLapDetection()
stopHeartRateQuery()
stopCalorieQuery()
WKInterfaceDevice.current().play(.notification)
}
func resumeWorkout() {
startTimer()
startLapDetection()
startHeartRateQuery()
startCalorieQuery()
WKInterfaceDevice.current().play(.start)
}
@@ -129,11 +147,27 @@ class WorkoutManager: ObservableObject {
isWorkoutActive = false
stopLapDetection()
stopHeartRateQuery()
stopCalorieQuery()
endHealthKitWorkout()
createWorkoutSummary()
syncCompletedWorkout()
WKInterfaceDevice.current().play(.success)
}
// MARK: - Workout Summary
private func createWorkoutSummary() {
let summary = WorkoutSummary(
duration: elapsedSeconds,
totalDistance: Int(totalDistanceSwum),
calories: Int(activeCalories),
laps: completedLaps
)
workoutSummary = summary
}
// MARK: - Lap Tracking
func completeLap() {
@@ -233,8 +267,87 @@ class WorkoutManager: ObservableObject {
return Double(set.distancePerRep) * Double(set.repCount)
}
var estimatedCalories: Double {
return totalDistanceSwum * 0.07
// MARK: - Heart Rate Query
private func startHeartRateQuery() {
guard let healthStore = healthStore else { return }
let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate)!
heartRateQuery = HKObserverQuery(sampleType: heartRateType, predicate: nil) { [weak self] _, completionHandler, _ in
self?.fetchLatestHeartRate()
completionHandler()
}
if let query = heartRateQuery {
healthStore.execute(query)
healthStore.enableBackgroundDelivery(for: heartRateType, frequency: .immediate) { _, _, _ in }
}
}
private func stopHeartRateQuery() {
if let query = heartRateQuery {
healthStore?.stop(query)
heartRateQuery = nil
}
}
private func fetchLatestHeartRate() {
guard let healthStore = healthStore else { return }
let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate)!
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
let query = HKSampleQuery(sampleType: heartRateType, predicate: nil, limit: 1, sortDescriptors: [sortDescriptor]) { [weak self] _, samples, _ in
guard let sample = samples?.first as? HKQuantitySample else { return }
let heartRateUnit = HKUnit.count().unitDivided(by: .minute())
let value = sample.quantity.doubleValue(for: heartRateUnit)
DispatchQueue.main.async {
self?.heartRate = value
}
}
healthStore.execute(query)
}
// MARK: - Calorie Query
private func startCalorieQuery() {
guard let healthStore = healthStore else { return }
let calorieType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!
calorieQuery = HKObserverQuery(sampleType: calorieType, predicate: nil) { [weak self] _, completionHandler, _ in
self?.fetchActiveCalories()
completionHandler()
}
if let query = calorieQuery {
healthStore.execute(query)
healthStore.enableBackgroundDelivery(for: calorieType, frequency: .immediate) { _, _, _ in }
}
}
private func stopCalorieQuery() {
if let query = calorieQuery {
healthStore?.stop(query)
calorieQuery = nil
}
}
private func fetchActiveCalories() {
guard let healthStore = healthStore else { return }
let calorieType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!
let now = Date()
let predicate = HKQuery.predicateForSamples(withStart: now.addingTimeInterval(-elapsedSeconds), end: now, options: .strictStartDate)
let query = HKStatisticsQuery(quantityType: calorieType, quantitySamplePredicate: predicate, options: .cumulativeSum) { [weak self] _, result, _ in
guard let sum = result?.sumQuantity() else { return }
let calories = sum.doubleValue(for: HKUnit.kilocalorie())
DispatchQueue.main.async {
self?.activeCalories = calories
}
}
healthStore.execute(query)
}
// MARK: - Lap Detection (Accelerometer)
@@ -349,7 +462,7 @@ class WorkoutManager: ObservableObject {
},
"duration": elapsedSeconds,
"totalDistance": Int(totalDistanceSwum),
"calories": Int(estimatedCalories),
"calories": Int(activeCalories),
]
var completed = completedWorkouts
@@ -393,3 +506,10 @@ struct CompletedLap {
let duration: TimeInterval
let heartRate: Int?
}
struct WorkoutSummary {
let duration: TimeInterval
let totalDistance: Int
let calories: Int
let laps: [CompletedLap]
}