From 0c5e3dae74fd44acd8a117290df32511dd6d537f Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sat, 21 Dec 2024 10:31:16 -0600 Subject: [PATCH] added healthkit Signed-off-by: Matt Bruce --- FitnessApp.xcodeproj/project.pbxproj | 4 + .../xcdebugger/Breakpoints_v2.xcbkptlist | 6 + FitnessApp/FitnessApp.entitlements | 8 ++ FitnessApp/FitnessAppApp.swift | 2 +- .../Home/ViewModels/HomeViewModel.swift | 61 ++++++++- FitnessApp/Home/Views/HomeView.swift | 12 +- FitnessApp/Managers/HealthManager.swift | 122 ++++++++++++++++++ 7 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 FitnessApp.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist create mode 100644 FitnessApp/FitnessApp.entitlements create mode 100644 FitnessApp/Managers/HealthManager.swift diff --git a/FitnessApp.xcodeproj/project.pbxproj b/FitnessApp.xcodeproj/project.pbxproj index 1fca30c..1e106dd 100644 --- a/FitnessApp.xcodeproj/project.pbxproj +++ b/FitnessApp.xcodeproj/project.pbxproj @@ -393,12 +393,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = FitnessApp/FitnessApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"FitnessApp/Preview Content\""; DEVELOPMENT_TEAM = 6R7KLBPBLZ; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHealthShareUsageDescription = "Please allow access to enjoy features of the app"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -422,12 +424,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = FitnessApp/FitnessApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"FitnessApp/Preview Content\""; DEVELOPMENT_TEAM = 6R7KLBPBLZ; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHealthShareUsageDescription = "Please allow access to enjoy features of the app"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/FitnessApp.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/FitnessApp.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..97ee746 --- /dev/null +++ b/FitnessApp.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/FitnessApp/FitnessApp.entitlements b/FitnessApp/FitnessApp.entitlements new file mode 100644 index 0000000..e10f430 --- /dev/null +++ b/FitnessApp/FitnessApp.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.healthkit + + + diff --git a/FitnessApp/FitnessAppApp.swift b/FitnessApp/FitnessAppApp.swift index cfad833..9d711bc 100644 --- a/FitnessApp/FitnessAppApp.swift +++ b/FitnessApp/FitnessAppApp.swift @@ -11,7 +11,7 @@ import SwiftUI struct FitnessAppApp: App { var body: some Scene { WindowGroup { - ContentView() + HomeView(viewModel: HomeViewModel()) } } } diff --git a/FitnessApp/Home/ViewModels/HomeViewModel.swift b/FitnessApp/Home/ViewModels/HomeViewModel.swift index 4511501..866b62a 100644 --- a/FitnessApp/Home/ViewModels/HomeViewModel.swift +++ b/FitnessApp/Home/ViewModels/HomeViewModel.swift @@ -8,11 +8,14 @@ import SwiftUI @Observable class HomeViewModel { + + let healthManager = HealthManager.shared + var activities: [Activity] = [] var workouts: [Workout] = [] - var calories: Int = 123 - var active: Int = 205 - var stand: Int = 80 + var calories: Int = 0 + var exercise: Int = 0 + var stand: Int = 0 var mockActivities = [ Activity(id: 0, title: "Today Steps", subtitle: "10,000 steps", image: "figure.walk", tintColor: .green, amount: "9,812"), @@ -27,4 +30,56 @@ class HomeViewModel { Workout(id: 2, title: "Walking", image: "figure.run", tintColor: .blue, duration: ".5 hrs", date: "Aug 3", calories: "250 kcal"), Workout(id: 3, title: "Bike", image: "figure.run", tintColor: .red, duration: "2 hrs", date: "Aug 29", calories: "500 kcal") ] + + init() { + Task { + do { + try await healthManager.requestHealthKitAccess() + fetchTodayStandHours() + fetchTodayCaloriesBurned() + fetchTodayExerciseTime() + } catch { + print(error.localizedDescription) + } + } + + func fetchTodayCaloriesBurned() { + healthManager.fetchTodayCaloriesBurned() { result in + switch result { + case .success(let calories): + DispatchQueue.main.async { + self.calories = Int(calories) + } + case .failure(let failure): + print(failure.localizedDescription) + } + } + } + + func fetchTodayExerciseTime() { + healthManager.fetchTodayExerciseTime() { result in + switch result { + case .success(let exercise): + DispatchQueue.main.async { + self.exercise = Int(exercise) + } + case .failure(let failure): + print(failure.localizedDescription) + } + } + } + + func fetchTodayStandHours() { + healthManager.fetchTodayStandHours() { result in + switch result { + case .success(let hours): + DispatchQueue.main.async { + self.stand = Int(hours) + } + case .failure(let failure): + print(failure.localizedDescription) + } + } + } + } } diff --git a/FitnessApp/Home/Views/HomeView.swift b/FitnessApp/Home/Views/HomeView.swift index f78ec7d..f233a28 100644 --- a/FitnessApp/Home/Views/HomeView.swift +++ b/FitnessApp/Home/Views/HomeView.swift @@ -21,7 +21,7 @@ struct HomeView: View { HStack { Spacer() - VStack { + VStack(alignment: .leading) { VStack(alignment: .leading, spacing: 8) { Text("Calories") .font(.callout) @@ -32,18 +32,18 @@ struct HomeView: View { .bold() }.padding(.bottom) - VStack { - Text("Active") + VStack(alignment: .leading, spacing: 8) { + Text("Exercise") .font(.callout) .bold() .foregroundColor(.green) - Text("\(viewModel.active)") + Text("\(viewModel.exercise)") .bold() }.padding(.bottom) - VStack { + VStack(alignment: .leading, spacing: 8) { Text("Stand") .font(.callout) .bold() @@ -60,7 +60,7 @@ struct HomeView: View { ZStack { Spacer() ProgressCircleView(progress: $viewModel.calories, goal: 600, color: .red) - ProgressCircleView(progress: $viewModel.active, goal: 600, color: .green).padding(.all, 20) + ProgressCircleView(progress: $viewModel.exercise, goal: 600, color: .green).padding(.all, 20) ProgressCircleView(progress: $viewModel.stand, goal: 600, color: .blue).padding(.all, 40) } .padding(.horizontal) diff --git a/FitnessApp/Managers/HealthManager.swift b/FitnessApp/Managers/HealthManager.swift new file mode 100644 index 0000000..4033be3 --- /dev/null +++ b/FitnessApp/Managers/HealthManager.swift @@ -0,0 +1,122 @@ +// +// HealthManager.swift +// FitnessApp +// +// Created by Matt Bruce on 12/20/24. +// + +import Foundation +import HealthKit + +extension Date { + static var startOfDay: Date { + let calendar = Calendar.current + return calendar.startOfDay(for: Date()) + } +} + +class HealthManager { + + static let shared = HealthManager() + + let healthStore = HKHealthStore() + + private init () { + Task { + do { + try await requestHealthKitAccess() + } catch { + + } + } + } + + func requestHealthKitAccess() async throws { + let calories = HKQuantityType(.activeEnergyBurned) + let exercise = HKQuantityType(.appleExerciseTime) + let stand = HKCategoryType(.appleStandHour) + + let healthTypes: Set = [calories, exercise, stand] + + try await healthStore.requestAuthorization(toShare: [], read: healthTypes) + } + + func fetchTodayCaloriesBurned(completion: @escaping(Result) -> Void) { + guard HKHealthStore.isHealthDataAvailable() else { + completion(.failure(NSError(domain: "HealthManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "Health data unavailable"]))) + return + } + + let calories = HKQuantityType(.activeEnergyBurned) + let predicate = HKQuery.predicateForSamples(withStart: .startOfDay, end: Date()) + + let query = HKStatisticsQuery(quantityType: calories, quantitySamplePredicate: predicate) { _, results, error in + if let error = error { + completion(.failure(error)) + return + } + + if let quantity = results?.sumQuantity() { + let caloriesBurned = quantity.doubleValue(for: .kilocalorie()) + completion(.success(caloriesBurned)) + } else { + completion(.failure(NSError(domain: "HealthManager", code: -3, userInfo: [NSLocalizedDescriptionKey: "No data found for today's calories"]))) + } + } + healthStore.execute(query) + } + + func fetchTodayExerciseTime(completion: @escaping(Result) -> Void) { + guard HKHealthStore.isHealthDataAvailable() else { + completion(.failure(NSError(domain: "HealthManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "Health data unavailable"]))) + return + } + + let exercise = HKQuantityType(.appleExerciseTime) + let predicate = HKQuery.predicateForSamples(withStart: .startOfDay, end: Date()) + + let query = HKStatisticsQuery(quantityType: exercise, quantitySamplePredicate: predicate) { _, results, error in + if let error = error { + completion(.failure(error)) + return + } + + if let quantity = results?.sumQuantity() { + let exerciseTime = quantity.doubleValue(for: .minute()) + completion(.success(exerciseTime)) + } else { + completion(.failure(NSError(domain: "HealthManager", code: -3, userInfo: [NSLocalizedDescriptionKey: "No exercise time data found for today."]))) + } + } + healthStore.execute(query) + } + + func fetchTodayStandHours(completion: @escaping(Result) -> Void) { + guard HKHealthStore.isHealthDataAvailable() else { + completion(.failure(NSError(domain: "HealthManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "Health data unavailable"]))) + return + } + + let stand = HKCategoryType(.appleStandHour) + let predicate = HKQuery.predicateForSamples(withStart: .startOfDay, end: Date()) + + let query = HKSampleQuery(sampleType: stand, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, results, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let samples = results as? [HKCategorySample], !samples.isEmpty else { + completion(.failure(NSError(domain: "HealthManager", code: -3, userInfo: [NSLocalizedDescriptionKey: "No stand data found for today."]))) + return + } + + // Count stand hours + let standHours = samples.filter { $0.value == HKCategoryValueAppleStandHour.stood.rawValue }.count + completion(.success(Double(standHours))) + } + + healthStore.execute(query) + } +} +