feat(watch): add lap detection via accelerometer and workout sync to phone

This commit is contained in:
2026-08-07 16:35:20 +02:00
parent 00a8220096
commit 48286041c6
8 changed files with 414 additions and 40 deletions
+123 -7
View File
@@ -3,6 +3,7 @@ import SwiftUI
import HealthKit
import WatchKit
import WatchConnectivity
import CoreMotion
class WorkoutManager: ObservableObject {
@Published var isWorkoutActive = false
@@ -25,6 +26,14 @@ class WorkoutManager: ObservableObject {
private var lapStartTime: Date?
private var completedLaps: [CompletedLap] = []
// 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
init() {
healthStore = HKHealthStore()
}
@@ -60,6 +69,22 @@ class WorkoutManager: ObservableObject {
saveWorkoutsToStorage()
}
func deleteSavedWorkouts(at offsets: IndexSet) {
savedWorkouts.remove(atOffsets: offsets)
saveWorkoutsToStorage()
}
func deleteCompletedWorkouts(at offsets: IndexSet) {
let limited = Array(completedWorkouts.prefix(3))
for i in offsets {
if i < limited.count {
let id = limited[i].id
completedWorkouts.removeAll { $0.id == id }
}
}
saveWorkoutsToStorage()
}
// MARK: - Workout Selection
func selectWorkout(_ workout: StoredWorkout) {
@@ -76,19 +101,25 @@ class WorkoutManager: ObservableObject {
currentRep = 0
elapsedSeconds = 0
completedLaps = []
wasStationary = false
stationarySince = nil
startHealthKitWorkout()
startTimer()
startLapDetection()
WKInterfaceDevice.current().enableWaterLock()
WKInterfaceDevice.current().play(.start)
}
func pauseWorkout() {
workoutTimer?.invalidate()
stopLapDetection()
WKInterfaceDevice.current().play(.notification)
}
func resumeWorkout() {
startTimer()
startLapDetection()
WKInterfaceDevice.current().play(.start)
}
@@ -97,6 +128,7 @@ class WorkoutManager: ObservableObject {
restTimer?.invalidate()
isWorkoutActive = false
stopLapDetection()
endHealthKitWorkout()
syncCompletedWorkout()
WKInterfaceDevice.current().play(.success)
@@ -178,6 +210,90 @@ class WorkoutManager: ObservableObject {
}
}
// MARK: - Computed Stats
var totalDistanceSwum: Double {
var distance: Double = 0
for lap in completedLaps {
distance += Double(lap.distance)
}
return distance
}
var currentSetDistanceSwum: Double {
guard let workout = selectedWorkout, currentSetIndex < workout.sets.count else { return 0 }
let setDistance = workout.sets[currentSetIndex].distancePerRep
let completedInSet = completedLaps.filter { $0.setPosition == currentSetIndex + 1 }.count
return Double(setDistance) * Double(completedInSet)
}
var currentSetTotalDistance: Double {
guard let workout = selectedWorkout, currentSetIndex < workout.sets.count else { return 0 }
let set = workout.sets[currentSetIndex]
return Double(set.distancePerRep) * Double(set.repCount)
}
var estimatedCalories: Double {
return totalDistanceSwum * 0.07
}
// MARK: - Lap Detection (Accelerometer)
private func startLapDetection() {
guard motionManager.isAccelerometerAvailable else { return }
motionManager.accelerometerUpdateInterval = 0.1 // 10 Hz
motionManager.startAccelerometerUpdates()
lapDetectionTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in
self?.processAccelerometerData()
}
}
private func stopLapDetection() {
lapDetectionTimer?.invalidate()
lapDetectionTimer = nil
motionManager.stopAccelerometerUpdates()
}
private func processAccelerometerData() {
guard let data = motionManager.accelerometerData else { return }
let magnitude = sqrt(data.acceleration.x * data.acceleration.x +
data.acceleration.y * data.acceleration.y +
data.acceleration.z * data.acceleration.z)
let isStationary = magnitude < 1.1 // Near rest (~1g from gravity)
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
}
// Reset after detection
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.lapDetected = false
}
}
}
wasStationary = false
stationarySince = nil
}
}
// MARK: - Timer
private func startTimer() {
@@ -220,6 +336,8 @@ class WorkoutManager: ObservableObject {
let workoutSummary: [String: Any] = [
"type": "workoutCompleted",
"workoutId": selectedWorkout?.id ?? "",
"name": selectedWorkout?.name ?? "Workout",
"poolLength": selectedWorkout?.poolLength ?? 0,
"laps": completedLaps.map { lap in
[
"setPosition": lap.setPosition,
@@ -230,6 +348,8 @@ class WorkoutManager: ObservableObject {
] as [String: Any]
},
"duration": elapsedSeconds,
"totalDistance": Int(totalDistanceSwum),
"calories": Int(estimatedCalories),
]
var completed = completedWorkouts
@@ -242,13 +362,9 @@ class WorkoutManager: ObservableObject {
UserDefaults.standard.set(data, forKey: "completedWorkouts")
}
if WCSession.default.isReachable {
WCSession.default.sendMessage(workoutSummary, replyHandler: nil) { error in
print("Failed to sync to phone: \(error)")
}
} else {
WCSession.default.transferUserInfo(workoutSummary)
}
// Always use transferUserInfo for reliable delivery
WCSession.default.transferUserInfo(workoutSummary)
print("[WorkoutManager] Synced completed workout to phone")
}
}