592 lines
19 KiB
Swift
592 lines
19 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import HealthKit
|
|
import WatchKit
|
|
import WatchConnectivity
|
|
import CoreMotion
|
|
|
|
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 currentLapDistance = 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
|
|
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 healthKitAuthorized = false
|
|
|
|
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)!,
|
|
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 = []
|
|
wasStationary = false
|
|
stationarySince = nil
|
|
|
|
startHealthKitWorkout(poolLength: selectedWorkout?.poolLength ?? 25)
|
|
startHeartRateQuery()
|
|
startCalorieQuery()
|
|
startTimer()
|
|
startLapDetection()
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
WKInterfaceDevice.current().enableWaterLock()
|
|
}
|
|
WKInterfaceDevice.current().play(.start)
|
|
}
|
|
|
|
func pauseWorkout() {
|
|
workoutTimer?.invalidate()
|
|
stopLapDetection()
|
|
stopHeartRateQuery()
|
|
stopCalorieQuery()
|
|
WKInterfaceDevice.current().play(.notification)
|
|
}
|
|
|
|
func resumeWorkout() {
|
|
startTimer()
|
|
startLapDetection()
|
|
startHeartRateQuery()
|
|
startCalorieQuery()
|
|
WKInterfaceDevice.current().play(.start)
|
|
}
|
|
|
|
func endWorkout() {
|
|
workoutTimer?.invalidate()
|
|
restTimer?.invalidate()
|
|
isWorkoutActive = false
|
|
|
|
stopLapDetection()
|
|
stopHeartRateQuery()
|
|
stopCalorieQuery()
|
|
endHealthKitWorkout()
|
|
|
|
createWorkoutSummary()
|
|
syncCompletedWorkout()
|
|
WKInterfaceDevice.current().play(.success)
|
|
}
|
|
|
|
// MARK: - Workout Summary
|
|
|
|
private func createWorkoutSummary() {
|
|
let summary = WorkoutSummary(
|
|
duration: elapsedSeconds,
|
|
totalDistance: Int(totalDistanceSwum),
|
|
calories: Int(activeCalories),
|
|
laps: completedLaps
|
|
)
|
|
workoutSummary = summary
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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 (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(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
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
|
|
// Handle collected events if needed
|
|
}
|
|
|
|
// 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(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?
|
|
}
|
|
|
|
struct WorkoutSummary {
|
|
let duration: TimeInterval
|
|
let totalDistance: Int
|
|
let calories: Int
|
|
let laps: [CompletedLap]
|
|
}
|