Files
SwimBuddy/targets/watch/WorkoutManager.swift
T

600 lines
19 KiB
Swift

import Foundation
import SwiftUI
import HealthKit
import WatchKit
import WatchConnectivity
class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
@Published var isWorkoutActive = false
@Published var savedWorkouts: [StoredWorkout] = []
@Published var completedWorkouts: [StoredWorkout] = []
@Published var selectedWorkout: StoredWorkout?
@Published var currentSetIndex = 0
@Published var currentRep = 0
@Published var currentRepDistanceSwum: Int = 0
@Published var elapsedSeconds: TimeInterval = 0
@Published var heartRate: Double = 0
@Published var activeCalories: Double = 0
@Published var isResting = false
@Published var restTimeRemaining: TimeInterval = 0
@Published var workoutSummary: WorkoutSummary?
private var workoutTimer: Timer?
private var restTimer: Timer?
private var healthStore: HKHealthStore?
private var activeWorkout: HKWorkoutSession?
private var builder: HKWorkoutBuilder?
private var lapStartTime: Date?
private var completedLaps: [CompletedLap] = []
// Heart rate & calorie queries
private var heartRateQuery: HKObserverQuery?
private var calorieQuery: HKObserverQuery?
// Lap detection
@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() {
super.init()
healthStore = HKHealthStore()
requestHealthKitAuthorization()
}
private func requestHealthKitAuthorization() {
guard let healthStore = healthStore else { return }
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> = [
HKObjectType.workoutType()
]
if healthStore.authorizationStatus(for: .workoutType()) == .sharingAuthorized {
DispatchQueue.main.async {
self.healthKitAuthorized = true
}
return
}
healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) { [weak self] success, error in
if let error = error {
print("HealthKit authorization failed: \(error)")
}
DispatchQueue.main.async {
self?.healthKitAuthorized = success
print("HealthKit authorization: \(success)")
}
}
}
// MARK: - Data Persistence
func loadSavedWorkouts() {
let defaults = UserDefaults.standard
if let data = defaults.data(forKey: "savedWorkouts"),
let workouts = try? JSONDecoder().decode([StoredWorkout].self, from: data) {
savedWorkouts = workouts
}
if let data = defaults.data(forKey: "completedWorkouts"),
let workouts = try? JSONDecoder().decode([StoredWorkout].self, from: data) {
completedWorkouts = workouts
}
}
func saveWorkoutsToStorage() {
let defaults = UserDefaults.standard
if let data = try? JSONEncoder().encode(savedWorkouts) {
defaults.set(data, forKey: "savedWorkouts")
}
if let data = try? JSONEncoder().encode(completedWorkouts) {
defaults.set(data, forKey: "completedWorkouts")
}
}
func addWorkoutFromPhone(_ plan: StoredWorkout) {
savedWorkouts.append(plan)
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) {
selectedWorkout = workout
}
func dismissSummary() {
workoutSummary = nil
}
// MARK: - Workout Control
func startWorkout() {
guard selectedWorkout != nil else { return }
if !healthKitAuthorized {
print("HealthKit not authorized, requesting...")
requestHealthKitAuthorization()
return
}
isWorkoutActive = true
currentSetIndex = 0
currentRep = 0
elapsedSeconds = 0
activeCalories = 0
heartRate = 0
completedLaps = []
healthKitLapCount = 0
currentStrokeCount = 0
currentRepDistanceSwum = 0
startHealthKitWorkout(poolLength: selectedWorkout?.poolLength ?? 25)
startHeartRateQuery()
startCalorieQuery()
startTimer()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
WKInterfaceDevice.current().enableWaterLock()
}
startWaterLockCheck()
WKInterfaceDevice.current().play(.start)
}
func pauseWorkout() {
workoutTimer?.invalidate()
stopHeartRateQuery()
stopCalorieQuery()
isPaused = true
WKInterfaceDevice.current().play(.notification)
}
func resumeWorkout() {
startTimer()
startHeartRateQuery()
startCalorieQuery()
isPaused = false
WKInterfaceDevice.current().play(.start)
}
func endWorkout() {
workoutTimer?.invalidate()
restTimer?.invalidate()
isWorkoutActive = false
stopWaterLockCheck()
stopHeartRateQuery()
stopCalorieQuery()
endHealthKitWorkout()
createWorkoutSummary()
syncCompletedWorkout()
WKInterfaceDevice.current().play(.success)
}
// MARK: - Water Lock Check
private func startWaterLockCheck() {
isWaterLockEnabled = WKInterfaceDevice.current().isWaterLockEnabled
waterLockCheckTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in
DispatchQueue.main.async {
self?.isWaterLockEnabled = WKInterfaceDevice.current().isWaterLockEnabled
}
}
}
private func stopWaterLockCheck() {
waterLockCheckTimer?.invalidate()
waterLockCheckTimer = nil
isWaterLockEnabled = false
}
// MARK: - Workout Summary
private func createWorkoutSummary() {
let summary = WorkoutSummary(
duration: elapsedSeconds,
totalDistance: Int(totalDistanceSwum),
calories: Int(activeCalories),
laps: completedLaps
)
workoutSummary = summary
}
// MARK: - Lap Tracking
private func advanceToNextRep() {
guard let workout = selectedWorkout else { return }
let currentSet = workout.sets[currentSetIndex]
currentRepDistanceSwum = 0
if currentRep + 1 < currentSet.repCount {
currentRep += 1
lapStartTime = Date()
} else if currentSetIndex + 1 < workout.sets.count {
startRestPeriod()
} else {
endWorkout()
}
}
// MARK: - Rest Period
private func startRestPeriod() {
guard let workout = selectedWorkout else { return }
let currentSet = workout.sets[currentSetIndex]
guard currentSet.restType != "none", currentSet.restDuration > 0 else {
moveToNextSet()
return
}
isResting = true
restTimeRemaining = currentSet.restDuration
WKInterfaceDevice.current().play(.notification)
restTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self = self else { return }
self.restTimeRemaining -= 1
if self.restTimeRemaining <= 0 {
self.restTimer?.invalidate()
self.moveToNextSet()
}
}
}
private func moveToNextSet() {
isResting = false
currentSetIndex += 1
currentRep = 0
lapStartTime = Date()
if currentSetIndex >= (selectedWorkout?.sets.count ?? 0) {
endWorkout()
} else {
WKInterfaceDevice.current().play(.start)
}
}
// MARK: - Computed Stats
var totalDistanceSwum: Double {
var distance: Double = 0
for lap in completedLaps {
distance += Double(lap.distance)
}
return distance
}
var currentSetDistanceSwum: Double {
completedLaps
.filter { $0.setPosition == currentSetIndex + 1 }
.reduce(0) { $0 + Double($1.distance) }
}
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)
}
// MARK: - Heart Rate Query
private func startHeartRateQuery() {
guard let healthStore = healthStore else { return }
let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate)!
heartRateQuery = HKObserverQuery(sampleType: heartRateType, predicate: nil) { [weak self] _, completionHandler, _ in
self?.fetchLatestHeartRate()
completionHandler()
}
if let query = heartRateQuery {
healthStore.execute(query)
healthStore.enableBackgroundDelivery(for: heartRateType, frequency: .immediate) { _, _ in }
}
}
private func stopHeartRateQuery() {
if let query = heartRateQuery {
healthStore?.stop(query)
heartRateQuery = nil
}
}
private func fetchLatestHeartRate() {
guard let healthStore = healthStore else { return }
let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate)!
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
let query = HKSampleQuery(sampleType: heartRateType, predicate: nil, limit: 1, sortDescriptors: [sortDescriptor]) { [weak self] _, samples, _ in
guard let sample = samples?.first as? HKQuantitySample else { return }
let heartRateUnit = HKUnit.count().unitDivided(by: .minute())
let value = sample.quantity.doubleValue(for: heartRateUnit)
DispatchQueue.main.async {
self?.heartRate = value
}
}
healthStore.execute(query)
}
// MARK: - Calorie Query
private func startCalorieQuery() {
guard let healthStore = healthStore else { return }
let calorieType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!
calorieQuery = HKObserverQuery(sampleType: calorieType, predicate: nil) { [weak self] _, completionHandler, _ in
self?.fetchActiveCalories()
completionHandler()
}
if let query = calorieQuery {
healthStore.execute(query)
healthStore.enableBackgroundDelivery(for: calorieType, frequency: .immediate) { _, _ in }
}
}
private func stopCalorieQuery() {
if let query = calorieQuery {
healthStore?.stop(query)
calorieQuery = nil
}
}
private func fetchActiveCalories() {
guard let healthStore = healthStore else { return }
let calorieType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!
let now = Date()
let predicate = HKQuery.predicateForSamples(withStart: now.addingTimeInterval(-elapsedSeconds), end: now, options: .strictStartDate)
let query = HKStatisticsQuery(quantityType: calorieType, quantitySamplePredicate: predicate, options: .cumulativeSum) { [weak self] _, result, _ in
guard let sum = result?.sumQuantity() else { return }
let calories = sum.doubleValue(for: HKUnit.kilocalorie())
DispatchQueue.main.async {
self?.activeCalories = calories
}
}
healthStore.execute(query)
}
// MARK: - Lap Detection (HealthKit-based)
// Lap detection is now handled by HKLiveWorkoutBuilderDelegate
// which processes HKWorkoutEvent.lap events from HealthKit's
// built-in swimming lap detection algorithm.
// MARK: - Timer
private func startTimer() {
lapStartTime = Date()
workoutTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.elapsedSeconds += 1
}
}
// MARK: - HealthKit
private func startHealthKitWorkout(poolLength: Double) {
guard let healthStore = healthStore else { return }
let config = HKWorkoutConfiguration()
config.activityType = .swimming
config.locationType = .indoor
config.swimmingLocationType = .pool
config.lapLength = HKQuantity(unit: .meter(), doubleValue: poolLength)
do {
activeWorkout = try HKWorkoutSession(healthStore: healthStore, configuration: config)
let workoutBuilder = activeWorkout!.associatedWorkoutBuilder()
workoutBuilder.delegate = self
self.builder = workoutBuilder
activeWorkout?.startActivity(with: Date())
workoutBuilder.beginCollection(withStart: Date()) { success, error in
if let error = error {
print("Failed to begin collection: \(error)")
}
}
} catch {
print("Failed to start HealthKit workout: \(error)")
}
}
private func endHealthKitWorkout() {
activeWorkout?.end()
builder?.endCollection(withEnd: Date()) { [weak self] success, error in
if let error = error {
print("Failed to end collection: \(error)")
}
self?.builder?.finishWorkout { _, _ in }
}
}
// MARK: - HKLiveWorkoutBuilderDelegate
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else { continue }
if let statistics = workoutBuilder.statistics(for: quantityType) {
DispatchQueue.main.async {
switch quantityType {
case HKQuantityType.quantityType(forIdentifier: .heartRate):
let heartRateUnit = HKUnit.count().unitDivided(by: .minute())
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
}
}
}
}
}
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
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 lapDistance = Int(workout.poolLength)
let lap = CompletedLap(
setPosition: currentSetIndex + 1,
repNumber: currentRep + 1,
distance: lapDistance,
duration: duration,
heartRate: Int(heartRate),
strokeType: workout.sets[currentSetIndex].stroke,
strokeCount: Int(currentStrokeCount)
)
completedLaps.append(lap)
currentStrokeCount = 0
currentRepDistanceSwum += lapDistance
WKInterfaceDevice.current().play(.click)
DispatchQueue.main.async {
self.lapDetected = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.lapDetected = false
}
if currentRepDistanceSwum >= workout.sets[currentSetIndex].distancePerRep {
advanceToNextRep()
}
lapStartTime = Date()
}
// MARK: - Sync to Phone
private func syncCompletedWorkout() {
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,
"repNumber": lap.repNumber,
"distance": lap.distance,
"duration": lap.duration,
"heartRate": lap.heartRate ?? 0,
"strokeType": lap.strokeType ?? "",
"strokeCount": lap.strokeCount ?? 0,
] as [String: Any]
},
"duration": elapsedSeconds,
"totalDistance": Int(totalDistanceSwum),
"calories": Int(activeCalories),
]
var completed = completedWorkouts
var summary = selectedWorkout
summary?.completedAt = ISO8601DateFormatter().string(from: Date())
if let s = summary {
completed.insert(s, at: 0)
}
if let data = try? JSONEncoder().encode(completed) {
UserDefaults.standard.set(data, forKey: "completedWorkouts")
}
// Always use transferUserInfo for reliable delivery
WCSession.default.transferUserInfo(workoutSummary)
print("[WorkoutManager] Synced completed workout to phone")
}
}
// MARK: - Models
struct StoredWorkout: Codable, Identifiable {
let id: String
let name: String?
let poolLength: Double
let sets: [StoredSet]
var completedAt: String?
}
struct StoredSet: Codable {
let stroke: String
let distancePerRep: Int
let repCount: Int
let restType: String
let restDuration: TimeInterval
}
struct CompletedLap {
let setPosition: Int
let repNumber: Int
let distance: Int
let duration: TimeInterval
let heartRate: Int?
let strokeType: String?
let strokeCount: Int?
}
struct WorkoutSummary {
let duration: TimeInterval
let totalDistance: Int
let calories: Int
let laps: [CompletedLap]
}