feat(watch): use HealthKit lap detection and track stroke count/swolf

This commit is contained in:
2026-08-11 16:03:08 +02:00
parent 098db881bb
commit 42e2f5fd47
2 changed files with 70 additions and 101 deletions
+6 -2
View File
@@ -146,6 +146,7 @@ export async function saveWatchCompletedWorkout(data: {
distance: number; distance: number;
duration: number; duration: number;
heartRate: number; heartRate: number;
strokeCount?: number;
}>; }>;
}) { }) {
const avgHeartRate = const avgHeartRate =
@@ -186,6 +187,9 @@ export async function saveWatchCompletedWorkout(data: {
} }
for (const lap of data.laps) { for (const lap of data.laps) {
const strokeCount = lap.strokeCount ?? null;
const swolf = strokeCount != null ? strokeCount + Math.round(lap.duration) : null;
await db.insert(laps).values({ await db.insert(laps).values({
id: generateUUID(), id: generateUUID(),
workoutId: data.workoutId, workoutId: data.workoutId,
@@ -194,8 +198,8 @@ export async function saveWatchCompletedWorkout(data: {
distance: lap.distance, distance: lap.distance,
duration: lap.duration, duration: lap.duration,
heartRate: lap.heartRate || null, heartRate: lap.heartRate || null,
strokeCount: null, strokeCount,
swolf: null, swolf,
}); });
} }
} }
+64 -99
View File
@@ -3,7 +3,6 @@ import SwiftUI
import HealthKit import HealthKit
import WatchKit import WatchKit
import WatchConnectivity import WatchConnectivity
import CoreMotion
class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate { class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
@Published var isWorkoutActive = false @Published var isWorkoutActive = false
@@ -33,16 +32,15 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
private var calorieQuery: HKObserverQuery? private var calorieQuery: HKObserverQuery?
// Lap detection // Lap detection
private let motionManager = CMMotionManager()
private var lapDetectionTimer: Timer?
private var lastLapDetectionTime: Date = .distantPast
private var wasStationary = false
private var stationarySince: Date?
@Published var lapDetected = false @Published var lapDetected = false
@Published var healthKitAuthorized = false @Published var healthKitAuthorized = false
@Published var isPaused = false @Published var isPaused = false
@Published var isWaterLockEnabled = false @Published var isWaterLockEnabled = false
// HealthKit lap tracking
private var currentStrokeCount: Double = 0
private var healthKitLapCount: Int = 0
private var waterLockCheckTimer: Timer? private var waterLockCheckTimer: Timer?
override init() { override init() {
@@ -56,6 +54,8 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
let typesToRead: Set<HKObjectType> = [ let typesToRead: Set<HKObjectType> = [
HKQuantityType.quantityType(forIdentifier: .heartRate)!, HKQuantityType.quantityType(forIdentifier: .heartRate)!,
HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!, HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!,
HKQuantityType.quantityType(forIdentifier: .swimmingStrokeCount)!,
HKQuantityType.quantityType(forIdentifier: .distanceSwimming)!,
HKObjectType.workoutType() HKObjectType.workoutType()
] ]
let typesToWrite: Set<HKSampleType> = [ let typesToWrite: Set<HKSampleType> = [
@@ -155,14 +155,13 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
activeCalories = 0 activeCalories = 0
heartRate = 0 heartRate = 0
completedLaps = [] completedLaps = []
wasStationary = false healthKitLapCount = 0
stationarySince = nil currentStrokeCount = 0
startHealthKitWorkout(poolLength: selectedWorkout?.poolLength ?? 25) startHealthKitWorkout(poolLength: selectedWorkout?.poolLength ?? 25)
startHeartRateQuery() startHeartRateQuery()
startCalorieQuery() startCalorieQuery()
startTimer() startTimer()
startLapDetection()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
WKInterfaceDevice.current().enableWaterLock() WKInterfaceDevice.current().enableWaterLock()
} }
@@ -172,7 +171,6 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
func pauseWorkout() { func pauseWorkout() {
workoutTimer?.invalidate() workoutTimer?.invalidate()
stopLapDetection()
stopHeartRateQuery() stopHeartRateQuery()
stopCalorieQuery() stopCalorieQuery()
isPaused = true isPaused = true
@@ -181,7 +179,6 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
func resumeWorkout() { func resumeWorkout() {
startTimer() startTimer()
startLapDetection()
startHeartRateQuery() startHeartRateQuery()
startCalorieQuery() startCalorieQuery()
isPaused = false isPaused = false
@@ -194,7 +191,6 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
isWorkoutActive = false isWorkoutActive = false
stopWaterLockCheck() stopWaterLockCheck()
stopLapDetection()
stopHeartRateQuery() stopHeartRateQuery()
stopCalorieQuery() stopCalorieQuery()
endHealthKitWorkout() endHealthKitWorkout()
@@ -235,26 +231,6 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
// MARK: - Lap Tracking // MARK: - Lap Tracking
func completeLap() {
guard let workout = selectedWorkout else { return }
let now = Date()
let duration = lapStartTime.map { now.timeIntervalSince($0) } ?? 0
let lap = CompletedLap(
setPosition: currentSetIndex + 1,
repNumber: currentRep + 1,
distance: workout.sets[currentSetIndex].distancePerRep,
duration: duration,
heartRate: Int(heartRate)
)
completedLaps.append(lap)
WKInterfaceDevice.current().play(.click)
advanceToNextRep()
}
private func advanceToNextRep() { private func advanceToNextRep() {
guard let workout = selectedWorkout else { return } guard let workout = selectedWorkout else { return }
let currentSet = workout.sets[currentSetIndex] let currentSet = workout.sets[currentSetIndex]
@@ -415,73 +391,11 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
healthStore.execute(query) healthStore.execute(query)
} }
// MARK: - Lap Detection (CMDeviceMotion) // MARK: - Lap Detection (HealthKit-based)
private var filteredUserAccel: Double = 0 // Lap detection is now handled by HKLiveWorkoutBuilderDelegate
private var filteredRotationX: Double = 0 // which processes HKWorkoutEvent.lap events from HealthKit's
private let filterAlpha: Double = 0.3 // built-in swimming lap detection algorithm.
private func startLapDetection() {
guard motionManager.isDeviceMotionAvailable else { return }
motionManager.deviceMotionUpdateInterval = 0.1 // 10 Hz
motionManager.startDeviceMotionUpdates(using: .xMagneticNorthZVertical)
lapDetectionTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in
self?.processDeviceMotion()
}
}
private func stopLapDetection() {
lapDetectionTimer?.invalidate()
lapDetectionTimer = nil
motionManager.stopDeviceMotionUpdates()
}
private func processDeviceMotion() {
guard let motion = motionManager.deviceMotion else { return }
// userAcceleration has gravity removed — clean movement signal
let rawUserAccel = sqrt(motion.userAcceleration.x * motion.userAcceleration.x +
motion.userAcceleration.y * motion.userAcceleration.y +
motion.userAcceleration.z * motion.userAcceleration.z)
// Low-pass filter for smoothing
filteredUserAccel = filterAlpha * rawUserAccel + (1 - filterAlpha) * filteredUserAccel
// Rotation rate on x-axis detects the flip/turn at the wall
let rawRotationX = abs(motion.rotationRate.x)
filteredRotationX = filterAlpha * rawRotationX + (1 - filterAlpha) * filteredRotationX
let isStationary = filteredUserAccel < 0.05
let now = Date()
if isStationary {
if !wasStationary {
wasStationary = true
stationarySince = now
}
} else {
if wasStationary, let since = stationarySince {
let pauseDuration = now.timeIntervalSince(since)
let timeSinceLastLap = now.timeIntervalSince(lastLapDetectionTime)
// Wall touch: was stationary for 0.3-3s, then pushed off
// Min 8s between laps to avoid false positives
if pauseDuration >= 0.3 && pauseDuration <= 3.0 && timeSinceLastLap >= 8.0 {
DispatchQueue.main.async {
self.completeLap()
self.lapDetected = true
self.lastLapDetectionTime = now
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.lapDetected = false
}
}
}
wasStationary = false
stationarySince = nil
}
}
// MARK: - Timer // MARK: - Timer
@@ -543,6 +457,8 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
self.heartRate = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) ?? 0 self.heartRate = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) ?? 0
case HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned): case HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned):
self.activeCalories = statistics.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0 self.activeCalories = statistics.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
case HKQuantityType.quantityType(forIdentifier: .swimmingStrokeCount):
self.currentStrokeCount = statistics.sumQuantity()?.doubleValue(for: .count()) ?? 0
default: default:
break break
} }
@@ -552,7 +468,52 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
} }
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) { func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
// Handle collected events if needed guard let events = workoutBuilder.workoutEvents as? [HKWorkoutEvent] else { return }
let lapEvents = events.filter { $0.type == .lap }
let newLapCount = lapEvents.count
if newLapCount > healthKitLapCount {
let lapsDelta = newLapCount - healthKitLapCount
healthKitLapCount = newLapCount
for _ in 0..<lapsDelta {
DispatchQueue.main.async {
self.completeLapFromHealthKit()
}
}
}
}
private func completeLapFromHealthKit() {
guard let workout = selectedWorkout else { return }
let now = Date()
let duration = lapStartTime.map { now.timeIntervalSince($0) } ?? 0
let lap = CompletedLap(
setPosition: currentSetIndex + 1,
repNumber: currentRep + 1,
distance: workout.sets[currentSetIndex].distancePerRep,
duration: duration,
heartRate: Int(heartRate),
strokeType: workout.sets[currentSetIndex].stroke,
strokeCount: Int(currentStrokeCount)
)
completedLaps.append(lap)
currentStrokeCount = 0
WKInterfaceDevice.current().play(.click)
DispatchQueue.main.async {
self.lapDetected = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.lapDetected = false
}
advanceToNextRep()
lapStartTime = Date()
} }
// MARK: - Sync to Phone // MARK: - Sync to Phone
@@ -570,6 +531,8 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
"distance": lap.distance, "distance": lap.distance,
"duration": lap.duration, "duration": lap.duration,
"heartRate": lap.heartRate ?? 0, "heartRate": lap.heartRate ?? 0,
"strokeType": lap.strokeType ?? "",
"strokeCount": lap.strokeCount ?? 0,
] as [String: Any] ] as [String: Any]
}, },
"duration": elapsedSeconds, "duration": elapsedSeconds,
@@ -617,6 +580,8 @@ struct CompletedLap {
let distance: Int let distance: Int
let duration: TimeInterval let duration: TimeInterval
let heartRate: Int? let heartRate: Int?
let strokeType: String?
let strokeCount: Int?
} }
struct WorkoutSummary { struct WorkoutSummary {