From 42e2f5fd475214be2260d85a3b79717c2db70bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=AB?= Date: Tue, 11 Aug 2026 16:03:08 +0200 Subject: [PATCH] feat(watch): use HealthKit lap detection and track stroke count/swolf --- src/db/operations.ts | 8 +- targets/watch/WorkoutManager.swift | 163 +++++++++++------------------ 2 files changed, 70 insertions(+), 101 deletions(-) diff --git a/src/db/operations.ts b/src/db/operations.ts index b55977f..3605985 100644 --- a/src/db/operations.ts +++ b/src/db/operations.ts @@ -146,6 +146,7 @@ export async function saveWatchCompletedWorkout(data: { distance: number; duration: number; heartRate: number; + strokeCount?: number; }>; }) { const avgHeartRate = @@ -186,6 +187,9 @@ export async function saveWatchCompletedWorkout(data: { } 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({ id: generateUUID(), workoutId: data.workoutId, @@ -194,8 +198,8 @@ export async function saveWatchCompletedWorkout(data: { distance: lap.distance, duration: lap.duration, heartRate: lap.heartRate || null, - strokeCount: null, - swolf: null, + strokeCount, + swolf, }); } } diff --git a/targets/watch/WorkoutManager.swift b/targets/watch/WorkoutManager.swift index 39a3c5e..719a905 100644 --- a/targets/watch/WorkoutManager.swift +++ b/targets/watch/WorkoutManager.swift @@ -3,7 +3,6 @@ import SwiftUI import HealthKit import WatchKit import WatchConnectivity -import CoreMotion class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate { @Published var isWorkoutActive = false @@ -33,16 +32,15 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate { private var calorieQuery: HKObserverQuery? // 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 healthKitAuthorized = false @Published var isPaused = false @Published var isWaterLockEnabled = false + // HealthKit lap tracking + private var currentStrokeCount: Double = 0 + private var healthKitLapCount: Int = 0 + private var waterLockCheckTimer: Timer? override init() { @@ -56,6 +54,8 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate { let typesToRead: Set = [ HKQuantityType.quantityType(forIdentifier: .heartRate)!, HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!, + HKQuantityType.quantityType(forIdentifier: .swimmingStrokeCount)!, + HKQuantityType.quantityType(forIdentifier: .distanceSwimming)!, HKObjectType.workoutType() ] let typesToWrite: Set = [ @@ -155,14 +155,13 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate { activeCalories = 0 heartRate = 0 completedLaps = [] - wasStationary = false - stationarySince = nil + healthKitLapCount = 0 + currentStrokeCount = 0 startHealthKitWorkout(poolLength: selectedWorkout?.poolLength ?? 25) startHeartRateQuery() startCalorieQuery() startTimer() - startLapDetection() DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { WKInterfaceDevice.current().enableWaterLock() } @@ -172,7 +171,6 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate { func pauseWorkout() { workoutTimer?.invalidate() - stopLapDetection() stopHeartRateQuery() stopCalorieQuery() isPaused = true @@ -181,7 +179,6 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate { func resumeWorkout() { startTimer() - startLapDetection() startHeartRateQuery() startCalorieQuery() isPaused = false @@ -194,7 +191,6 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate { isWorkoutActive = false stopWaterLockCheck() - stopLapDetection() stopHeartRateQuery() stopCalorieQuery() endHealthKitWorkout() @@ -235,26 +231,6 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate { // 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() { guard let workout = selectedWorkout else { return } let currentSet = workout.sets[currentSetIndex] @@ -415,73 +391,11 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate { healthStore.execute(query) } - // MARK: - Lap Detection (CMDeviceMotion) + // MARK: - Lap Detection (HealthKit-based) - private var filteredUserAccel: Double = 0 - private var filteredRotationX: Double = 0 - private let filterAlpha: Double = 0.3 - - 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 - } - } + // Lap detection is now handled by HKLiveWorkoutBuilderDelegate + // which processes HKWorkoutEvent.lap events from HealthKit's + // built-in swimming lap detection algorithm. // MARK: - Timer @@ -543,6 +457,8 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate { self.heartRate = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) ?? 0 case HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned): self.activeCalories = statistics.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0 + case HKQuantityType.quantityType(forIdentifier: .swimmingStrokeCount): + self.currentStrokeCount = statistics.sumQuantity()?.doubleValue(for: .count()) ?? 0 default: break } @@ -552,7 +468,52 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate { } 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..