diff --git a/Werkout_ios.xcodeproj/project.pbxproj b/Werkout_ios.xcodeproj/project.pbxproj index 23ca4e8..80d1fb9 100644 --- a/Werkout_ios.xcodeproj/project.pbxproj +++ b/Werkout_ios.xcodeproj/project.pbxproj @@ -43,6 +43,7 @@ 1CD0C6672A5CA19600970E52 /* BaseURLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD0C6662A5CA19600970E52 /* BaseURLs.swift */; }; 1CD0C6682A5CA1A200970E52 /* BaseURLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD0C6662A5CA19600970E52 /* BaseURLs.swift */; }; 1CD0C66C2A5E4EA100970E52 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1CD0C66B2A5E4EA100970E52 /* LaunchScreen.storyboard */; }; + 1CEF74AB2A89937800C1AE6A /* HealthKitHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CEF74AA2A89937800C1AE6A /* HealthKitHelper.swift */; }; 1CF65A262A3972840042FFBD /* Werkout_iosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A252A3972840042FFBD /* Werkout_iosApp.swift */; }; 1CF65A282A3972840042FFBD /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A272A3972840042FFBD /* Persistence.swift */; }; 1CF65A2B2A3972840042FFBD /* Werkout_ios.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 1CF65A292A3972840042FFBD /* Werkout_ios.xcdatamodeld */; }; @@ -160,6 +161,7 @@ 1CD0C6622A5AF62900970E52 /* WorkoutOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutOverviewView.swift; sourceTree = ""; }; 1CD0C6662A5CA19600970E52 /* BaseURLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseURLs.swift; sourceTree = ""; }; 1CD0C66B2A5E4EA100970E52 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + 1CEF74AA2A89937800C1AE6A /* HealthKitHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitHelper.swift; sourceTree = ""; }; 1CF65A222A3972840042FFBD /* Werkout_ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Werkout_ios.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1CF65A252A3972840042FFBD /* Werkout_iosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Werkout_iosApp.swift; sourceTree = ""; }; 1CF65A272A3972840042FFBD /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; @@ -293,6 +295,7 @@ 1CF65A292A3972840042FFBD /* Werkout_ios.xcdatamodeld */, 1CF65A312A3972850042FFBD /* Preview Content */, 1CF65A862A4400E10042FFBD /* ToDo */, + 1CEF74AA2A89937800C1AE6A /* HealthKitHelper.swift */, ); path = Werkout_ios; sourceTree = ""; @@ -612,6 +615,7 @@ 1C5190CC2A589D0000885849 /* CountdownView.swift in Sources */, 1CF65A2B2A3972840042FFBD /* Werkout_ios.xcdatamodeld in Sources */, 1C31C8872A55B2CC00350540 /* PlayerUIView.swift in Sources */, + 1CEF74AB2A89937800C1AE6A /* HealthKitHelper.swift in Sources */, 1CF65A2D2A3972840042FFBD /* MainView.swift in Sources */, 1CF65A7D2A41275D0042FFBD /* Network.swift in Sources */, 1C485C8A2A492BB400A6F896 /* LoginView.swift in Sources */, diff --git a/Werkout_ios/BridgeModule.swift b/Werkout_ios/BridgeModule.swift index 54045e8..61eb0b9 100644 --- a/Werkout_ios/BridgeModule.swift +++ b/Werkout_ios/BridgeModule.swift @@ -50,8 +50,7 @@ class BridgeModule: NSObject, ObservableObject { // workoutEndDate fills out WatchPackageModel.workoutEndDate which // tells the watch app to stop the workout public private(set) var workoutEndDate: Date? - public private(set) var totalCaloire: Float? - public private(set) var heartRates: [Int]? + public private(set) var healthKitUUID: UUID? var audioPlayer: AVAudioPlayer? var avPlayer: AVPlayer? @@ -315,8 +314,7 @@ extension BridgeModule: WCSessionDelegate { playFinished() case .workoutComplete(let data): let model = try! JSONDecoder().decode(WatchFinishWorkoutModel.self, from: data) - totalCaloire = Float(model.totalBurnedEnergery) - heartRates = model.allHeartRates + healthKitUUID = model.healthKitUUID completedWorkout?() case .restartExercise: restartExercise() diff --git a/Werkout_ios/CurrentWorkoutInfo.swift b/Werkout_ios/CurrentWorkoutInfo.swift index 6c99ee8..7fce53c 100644 --- a/Werkout_ios/CurrentWorkoutInfo.swift +++ b/Werkout_ios/CurrentWorkoutInfo.swift @@ -53,7 +53,6 @@ class CurrentWorkoutInfo { currentRound = 1 if supersetIndex >= supersets.count { - complete?() return nil } } diff --git a/Werkout_ios/HealthKitHelper.swift b/Werkout_ios/HealthKitHelper.swift new file mode 100644 index 0000000..1abfb2e --- /dev/null +++ b/Werkout_ios/HealthKitHelper.swift @@ -0,0 +1,126 @@ +// +// HealthKitHelper.swift +// Werkout_ios +// +// Created by Trey Tartt on 8/13/23. +// + +import Foundation +import HealthKit + +struct HealthKitWorkoutData { + var caloriesBurned: Double? + var minHeartRate: Double? + var maxHeartRate: Double? + var avgHeartRate: Double? +} + +class HealthKitHelper { + // this is dirty and i dont care + var returnCount = 0 + let healthStore = HKHealthStore() + + var healthKitWorkoutData = HealthKitWorkoutData( + caloriesBurned: nil, + minHeartRate: nil, + maxHeartRate: nil, + avgHeartRate: nil) + + var completion: ((HealthKitWorkoutData) -> Void)? + + func getDetails(forHealthKitUUID uuid: UUID, completion: @escaping ((HealthKitWorkoutData) -> Void)) { + self.completion = completion + self.returnCount = 0 + + print("get details \(uuid.uuidString)") + + let predicate = HKQuery.predicateForObject(with: uuid) + let query = HKSampleQuery(sampleType: HKWorkoutType.workoutType(), + predicate: predicate, + limit: 0, + sortDescriptors: nil) + { (sampleQuery, results, error ) -> Void in + + if let queryError = error { + print( "There was an error while reading the samples: \(queryError.localizedDescription)") + } else { + for samples: HKSample in results! { + let workout: HKWorkout = (samples as! HKWorkout) + self.getTotalBurned(forWorkout: workout) + self.getHeartRateStuff(forWorkout: workout) + print("got workout") + } + } + } + healthStore.execute(query) + } + + func getHeartRateStuff(forWorkout workout: HKWorkout) { + print("get heart") + let heartType = HKQuantityType.quantityType(forIdentifier: .heartRate) + let heartPredicate: NSPredicate? = HKQuery.predicateForSamples(withStart: workout.startDate, + end: workout.endDate, + options: HKQueryOptions.strictEndDate) + + let heartQuery = HKStatisticsQuery(quantityType: heartType!, + quantitySamplePredicate: heartPredicate, + options: [.discreteAverage, .discreteMin, .discreteMax], + completionHandler: {(query: HKStatisticsQuery, result: HKStatistics?, error: Error?) -> Void in + if let result = result, + let minValue = result.minimumQuantity(), + let maxValue = result.maximumQuantity(), + let avgValue = result.averageQuantity() { + + let _minHeartRate = minValue.doubleValue( + for: HKUnit(from: "count/min") + ) + + let _maxHeartRate = maxValue.doubleValue( + for: HKUnit(from: "count/min") + ) + + let _avgHeartRate = avgValue.doubleValue( + for: HKUnit(from: "count/min") + ) + self.healthKitWorkoutData.avgHeartRate = _avgHeartRate + self.healthKitWorkoutData.minHeartRate = _minHeartRate + self.healthKitWorkoutData.maxHeartRate = _maxHeartRate + print("got heart") + DispatchQueue.main.async { + self.shitReturned() + } + } + }) + healthStore.execute(heartQuery) + } + + func getTotalBurned(forWorkout workout: HKWorkout) { + print("get total burned") + let calType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) + let calPredicate: NSPredicate? = HKQuery.predicateForSamples(withStart: workout.startDate, + end: workout.endDate, + options: HKQueryOptions.strictEndDate) + + let calQuery = HKStatisticsQuery(quantityType: calType!, + quantitySamplePredicate: calPredicate, + options: [.cumulativeSum], + completionHandler: {(query: HKStatisticsQuery, result: HKStatistics?, error: Error?) -> Void in + if let result = result { + self.healthKitWorkoutData.caloriesBurned = result.sumQuantity()?.doubleValue(for: HKUnit.kilocalorie()) ?? -1 + print("got total burned") + DispatchQueue.main.async { + self.shitReturned() + } + } + }) + healthStore.execute(calQuery) + } + + func shitReturned() { + returnCount += 1 + print("\(returnCount)") + if returnCount == 2 { + self.completion!(healthKitWorkoutData) + } + } +} diff --git a/Werkout_ios/Network/Network.swift b/Werkout_ios/Network/Network.swift index 445bea3..01f728f 100644 --- a/Werkout_ios/Network/Network.swift +++ b/Werkout_ios/Network/Network.swift @@ -87,7 +87,7 @@ extension Postable { let postData = try! JSONSerialization.data(withJSONObject:postableData) - var request = URLRequest(url: url,timeoutInterval: Double.infinity) + var request = URLRequest(url: url,timeoutInterval: Double.infinity) if attachToken { guard let token = UserStore.shared.token else { completion(.failure(.noPostData)) diff --git a/Werkout_ios/Views/AllWorkouts/AllWorkoutsView.swift b/Werkout_ios/Views/AllWorkouts/AllWorkoutsView.swift index 705ea6f..ef92ef5 100644 --- a/Werkout_ios/Views/AllWorkouts/AllWorkoutsView.swift +++ b/Werkout_ios/Views/AllWorkouts/AllWorkoutsView.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI +import HealthKit enum MainViewTypes: Int, CaseIterable { case AllWorkout = 0 @@ -27,7 +28,7 @@ struct AllWorkoutsView: View { @State var isUpdating = false @State var workouts: [Workout]? @State var uniqueWorkoutUsers: [RegisteredUser]? - + let healthStore = HKHealthStore() var bridgeModule = BridgeModule.shared @State public var needsUpdating: Bool = true @@ -88,6 +89,7 @@ struct AllWorkoutsView: View { } }.onAppear{ // UserStore.shared.logout() + authorizeHealthKit() maybeUpdateShit() } .sheet(item: $selectedWorkout) { item in @@ -178,6 +180,21 @@ struct AllWorkoutsView: View { showLoginView = true } } + + func authorizeHealthKit() { + let healthKitTypes: Set = [ + HKObjectType.quantityType(forIdentifier: .heartRate)!, + HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!, + HKObjectType.quantityType(forIdentifier: .oxygenSaturation)!, + HKQuantityType.workoutType() + ] + + healthStore.requestAuthorization(toShare: healthKitTypes, read: healthKitTypes) { (succ, error) in + if !succ { + fatalError("Error requesting authorization from health store: \(String(describing: error)))") + } + } + } } struct AllWorkoutsView_Previews: PreviewProvider { diff --git a/Werkout_ios/Views/CompletedWorkout/CompletedWorkoutView.swift b/Werkout_ios/Views/CompletedWorkout/CompletedWorkoutView.swift index 34df422..198c1c3 100644 --- a/Werkout_ios/Views/CompletedWorkout/CompletedWorkoutView.swift +++ b/Werkout_ios/Views/CompletedWorkout/CompletedWorkoutView.swift @@ -6,17 +6,23 @@ // import SwiftUI +import HealthKit struct CompletedWorkoutView: View { @ObservedObject var bridgeModule = BridgeModule.shared var postData: [String: Any] + let healthKitHelper = HealthKitHelper() let workout: Workout + let healthKitUUID: UUID? + @State var healthKitWorkoutData: HealthKitWorkoutData? + @Environment(\.dismiss) var dismiss @State var difficulty: Float = 0 @State var notes: String = "" let completedWorkoutDismissed: ((Bool) -> Void)? @State var isUploading: Bool = false + @State var gettingHealthKitData: Bool = false var body: some View { ZStack { @@ -29,11 +35,40 @@ struct CompletedWorkoutView: View { Divider() HStack { - calsBurned() - .frame(maxWidth: .infinity) - - heartRates() - .frame(maxWidth: .infinity) + if let calsBurned = healthKitWorkoutData?.caloriesBurned { + HStack { + HStack { + Image(systemName: "flame.fill") + .foregroundColor(.orange) + .font(.title) + VStack { + Text("\(calsBurned, specifier: "%.0f")") + } + } + .frame(maxWidth: .infinity) + } + + if let minHeart = healthKitWorkoutData?.minHeartRate, + let maxHeart = healthKitWorkoutData?.maxHeartRate, + let avgHeart = healthKitWorkoutData?.avgHeartRate { + VStack { + HStack { + Image(systemName: "heart") + .foregroundColor(.red) + .font(.title) + VStack { + HStack { + Text("\(minHeart, specifier: "%.0f")") + Text("-") + Text("\(maxHeart, specifier: "%.0f")") + } + Text("\(avgHeart, specifier: "%.0f")") + } + } + } + .frame(maxWidth: .infinity) + } + } } rateWorkout() @@ -48,6 +83,11 @@ struct CompletedWorkoutView: View { .overlay(RoundedRectangle(cornerRadius: 16).stroke(Color(uiColor: .clear))).background(Color(uiColor: .init(red: 200/255, green: 200/255, blue: 200/255, alpha: 0.2))) .cornerRadius(8) + if gettingHealthKitData { + ProgressView("Getting HealthKit data") + .padding() + } + Spacer() Button("Upload", action: { @@ -64,6 +104,16 @@ struct CompletedWorkoutView: View { } .padding([.leading, .trailing]) } + .onAppear{ + if let healthKitUUID = healthKitUUID { + gettingHealthKitData = true + healthKitHelper.getDetails(forHealthKitUUID: healthKitUUID, + completion: { healthKitWorkoutData in + self.healthKitWorkoutData = healthKitWorkoutData + gettingHealthKitData = false + }) + } + } } func topViews() -> some View { @@ -83,26 +133,13 @@ struct CompletedWorkoutView: View { } } - func calsBurned() -> some View { - VStack { - if let cals = postData["total_calories"] as? Float { - HStack { - Image(systemName: "flame.fill") - .foregroundColor(.orange) - .font(.title) - VStack { - Text("\(cals, specifier: "%.0f")") - } - } - } - } - } - func rateWorkout() -> some View { VStack { Divider() HStack { + Text("No Rate") + .foregroundColor(.black) Text("Easy") .foregroundColor(.green) Spacer() @@ -112,7 +149,7 @@ struct CompletedWorkoutView: View { ZStack { LinearGradient( - gradient: Gradient(colors: [.green, .red]), + gradient: Gradient(colors: [.black, .green, .red]), startPoint: .leading, endPoint: .trailing ) @@ -125,35 +162,15 @@ struct CompletedWorkoutView: View { } } } - - func heartRates() -> some View { - VStack { - if let heartRates = postData["heart_rates"] as? [Int], - heartRates.count > 0 { - let avg = heartRates.reduce(0, +)/heartRates.count - HStack { - Image(systemName: "heart") - .foregroundColor(.red) - .font(.title) - VStack { - HStack { - Text("\(heartRates.min() ?? 0)") - Text("-") - Text("\(heartRates.max() ?? 0)") - } - Text("\(avg)") - } - } - } - } - } - + func upload(postBody: [String: Any]) { var _postBody = postBody _postBody["difficulty"] = difficulty _postBody["notes"] = notes - - + if let healthKitUUID = healthKitUUID { + _postBody["health_kit_workout_uuid"] = healthKitUUID.uuidString + } + CompleteWorkoutFetchable(postData: _postBody).fetch(completion: { result in switch result { case .success(_): @@ -163,6 +180,9 @@ struct CompletedWorkoutView: View { completedWorkoutDismissed?(true) } case .failure(let failure): + DispatchQueue.main.async { + self.isUploading = false + } print(failure) } }) @@ -184,6 +204,7 @@ struct CompletedWorkoutView_Previews: PreviewProvider { static var previews: some View { CompletedWorkoutView(postData: CompletedWorkoutView_Previews.postBody, workout: workout, + healthKitUUID: nil, completedWorkoutDismissed: { _ in }) } } diff --git a/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift b/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift index 8341aa1..93b09eb 100644 --- a/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift +++ b/Werkout_ios/Views/WorkoutDetail/WorkoutDetailView.swift @@ -102,7 +102,10 @@ struct WorkoutDetailView: View { .sheet(item: $presentedSheet) { item in switch item { case .completedWorkout(let data): - CompletedWorkoutView(postData: data, workout: workout, completedWorkoutDismissed: { uploaded in + CompletedWorkoutView(postData: data, + workout: workout, + healthKitUUID: bridgeModule.healthKitUUID, + completedWorkoutDismissed: { uploaded in if uploaded { dismiss() } @@ -179,9 +182,7 @@ struct WorkoutDetailView: View { "workout_start_time": startTime, "workout_end_time": endTime, "workout": workoutid, - "total_time": bridgeModule.currentWorkoutRunTimeInSeconds, - "total_calories": bridgeModule.totalCaloire ?? -1, - "heart_rates": bridgeModule.heartRates ?? [Int]() + "total_time": bridgeModule.currentWorkoutRunTimeInSeconds ] as [String : Any] return postBody diff --git a/Werkout_ios/WatchPackageModel.swift b/Werkout_ios/WatchPackageModel.swift index 539428d..68d76d3 100644 --- a/Werkout_ios/WatchPackageModel.swift +++ b/Werkout_ios/WatchPackageModel.swift @@ -15,6 +15,5 @@ struct WatchPackageModel: Codable { } struct WatchFinishWorkoutModel: Codable { - var totalBurnedEnergery: Double - var allHeartRates: [Int] + var healthKitUUID: UUID } diff --git a/Werkout_watch Watch App/WatchMainViewModel.swift b/Werkout_watch Watch App/WatchMainViewModel.swift index 5e6ca49..bc54994 100644 --- a/Werkout_watch Watch App/WatchMainViewModel.swift +++ b/Werkout_watch Watch App/WatchMainViewModel.swift @@ -39,8 +39,9 @@ class WatchMainViewModel: NSObject, ObservableObject { func autorizeHealthKit() { let healthKitTypes: Set = [ - HKObjectType.quantityType(forIdentifier: HKQuantityTypeIdentifier.heartRate)!, + HKObjectType.quantityType(forIdentifier: .heartRate)!, HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!, + HKObjectType.quantityType(forIdentifier: .oxygenSaturation)!, HKQuantityType.workoutType() ] healthStore.requestAuthorization(toShare: healthKitTypes, read: healthKitTypes) { (succ, error) in @@ -49,22 +50,7 @@ class WatchMainViewModel: NSObject, ObservableObject { } } } - - private func startHeartRateQuery(quantityTypeIdentifier: HKQuantityTypeIdentifier) { - let devicePredicate = HKQuery.predicateForObjects(from: [HKDevice.local()]) - let updateHandler: (HKAnchoredObjectQuery, [HKSample]?, [HKDeletedObject]?, HKQueryAnchor?, Error?) -> Void = { - query, samples, deletedObjects, queryAnchor, error in - guard let samples = samples as? [HKQuantitySample] else { - return - } - } - - let query = HKAnchoredObjectQuery(type: HKObjectType.quantityType(forIdentifier: quantityTypeIdentifier)!, predicate: devicePredicate, anchor: nil, limit: HKObjectQueryNoLimit, resultsHandler: updateHandler) - - query.updateHandler = updateHandler - healthStore.execute(query) - } - + func nextExercise() { let nextExerciseAction = WatchActions.nextExercise let data = try! JSONEncoder().encode(nextExerciseAction) @@ -153,8 +139,13 @@ extension WatchMainViewModel { DispatchQueue.main.async() { self.hkWorkoutSession = nil self.hkBuilder = nil - let totalEnergy = workout?.totalEnergyBurned?.doubleValue(for: .kilocalorie()) ?? -1 - let watchFinishWorkoutModel = WatchFinishWorkoutModel(totalBurnedEnergery: totalEnergy, allHeartRates: self.heartRates) + self.heartRates.removeAll() + self.isInWorkout = false + + guard let id = workout?.uuid else { + return + } + let watchFinishWorkoutModel = WatchFinishWorkoutModel(healthKitUUID: id) let data = try! JSONEncoder().encode(watchFinishWorkoutModel) let watchAction = WatchActions.workoutComplete(data) let watchActionData = try! JSONEncoder().encode(watchAction) @@ -162,9 +153,6 @@ extension WatchMainViewModel { if sendDetails { self.send(watchActionData) } - - self.heartRates.removeAll() - self.isInWorkout = false } } }