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;
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user