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 = [ HKQuantityType.quantityType(forIdentifier: .heartRate)!, HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!, HKQuantityType.quantityType(forIdentifier: .swimmingStrokeCount)!, HKQuantityType.quantityType(forIdentifier: .distanceSwimming)!, HKObjectType.workoutType() ] let typesToWrite: Set = [ 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) { 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..= 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] }