feat(watch): use HealthKit lap detection and track stroke count/swolf
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HKObjectType> = [
|
||||
HKQuantityType.quantityType(forIdentifier: .heartRate)!,
|
||||
HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!,
|
||||
HKQuantityType.quantityType(forIdentifier: .swimmingStrokeCount)!,
|
||||
HKQuantityType.quantityType(forIdentifier: .distanceSwimming)!,
|
||||
HKObjectType.workoutType()
|
||||
]
|
||||
let typesToWrite: Set<HKSampleType> = [
|
||||
@@ -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..<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
|
||||
@@ -570,6 +531,8 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
||||
"distance": lap.distance,
|
||||
"duration": lap.duration,
|
||||
"heartRate": lap.heartRate ?? 0,
|
||||
"strokeType": lap.strokeType ?? "",
|
||||
"strokeCount": lap.strokeCount ?? 0,
|
||||
] as [String: Any]
|
||||
},
|
||||
"duration": elapsedSeconds,
|
||||
@@ -617,6 +580,8 @@ struct CompletedLap {
|
||||
let distance: Int
|
||||
let duration: TimeInterval
|
||||
let heartRate: Int?
|
||||
let strokeType: String?
|
||||
let strokeCount: Int?
|
||||
}
|
||||
|
||||
struct WorkoutSummary {
|
||||
|
||||
Reference in New Issue
Block a user