import Foundation import SwiftUI import HealthKit import WatchKit import WatchConnectivity import CoreMotion class WorkoutManager: ObservableObject { @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 currentLapDistance = 0 @Published var elapsedSeconds: TimeInterval = 0 @Published var heartRate: Double = 0 @Published var isResting = false @Published var restTimeRemaining: TimeInterval = 0 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] = [] // 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() } // 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 } // MARK: - Workout Control func startWorkout() { guard selectedWorkout != nil else { return } isWorkoutActive = true currentSetIndex = 0 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) } func endWorkout() { workoutTimer?.invalidate() restTimer?.invalidate() isWorkoutActive = false stopLapDetection() endHealthKitWorkout() syncCompletedWorkout() WKInterfaceDevice.current().play(.success) } // 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] 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 { 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() { lapStartTime = Date() workoutTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in self?.elapsedSeconds += 1 } } // MARK: - HealthKit private func startHealthKitWorkout() { guard let healthStore = healthStore else { return } let config = HKWorkoutConfiguration() config.activityType = .swimming config.locationType = .indoor do { activeWorkout = try HKWorkoutSession(healthStore: healthStore, configuration: config) let workoutBuilder = HKWorkoutBuilder(healthStore: healthStore, configuration: config, device: nil) self.builder = workoutBuilder activeWorkout?.startActivity(with: Date()) workoutBuilder.beginCollection(withStart: Date()) { _, _ in } } catch { print("Failed to start HealthKit workout: \(error)") } } private func endHealthKitWorkout() { activeWorkout?.end() builder?.endCollection(withEnd: Date()) { [weak self] _, _ in self?.builder?.finishWorkout { _, _ in } } } // 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, ] as [String: Any] }, "duration": elapsedSeconds, "totalDistance": Int(totalDistanceSwum), "calories": Int(estimatedCalories), ] 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? }