// // HealthManager.swift // FitnessApp // // Created by Matt Bruce on 12/20/24. // import SwiftUI import Foundation import HealthKit extension Date { static var startOfDay: Date { let calendar = Calendar.current return calendar.startOfDay(for: Date()) } static var startOfWeek: Date { let calendar = Calendar.current var components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date()) components.weekday = 2 return calendar.date(from: components) ?? Date() } } extension Double { func formattedNumberString() -> String { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 0 return formatter.string(from: NSNumber(value: self)) ?? "0" } } /// A centralized manager for accessing HealthKit data class HealthManager { static let shared = HealthManager() private let healthStore = HKHealthStore() private init() { Task { do { try await requestHealthKitAccess() } catch { print("Failed to request HealthKit access: \(error.localizedDescription)") } } } } // MARK: - HealthKit Authorization extension HealthManager { /// Requests HealthKit access for necessary data types func requestHealthKitAccess() async throws { let readTypes: Set = [ HKQuantityType(.activeEnergyBurned), HKQuantityType(.appleExerciseTime), HKCategoryType(.appleStandHour), HKQuantityType(.stepCount), HKQuantityType.workoutType() ] try await healthStore.requestAuthorization(toShare: [], read: readTypes) } /// Ensures HealthKit data is available on this device private func ensureHealthDataAvailable() throws { guard HKHealthStore.isHealthDataAvailable() else { throw HealthManagerError.healthDataUnavailable } } } // MARK: - Error Handling extension HealthManager { enum HealthManagerError: Error { case healthDataUnavailable case noDataFound(String) var localizedDescription: String { switch self { case .healthDataUnavailable: return "Health data is unavailable on this device." case .noDataFound(let dataType): return "No \(dataType) data found for today." } } } } // MARK: - Query Execution extension HealthManager { /// Executes a statistics query and processes the result private func executeStatisticsQuery( quantityType: HKQuantityType, predicate: NSPredicate, unit: HKUnit, completion: @escaping (Result) -> Void ) { let query = HKStatisticsQuery(quantityType: quantityType, quantitySamplePredicate: predicate) { _, results, error in if let error = error { completion(.failure(error)) return } if let quantity = results?.sumQuantity() { let value = quantity.doubleValue(for: unit) completion(.success(value)) } else { completion(.failure(HealthManagerError.noDataFound(quantityType.identifier))) } } healthStore.execute(query) } /// Executes a sample query and processes the result private func executeSampleQuery( sampleType: HKSampleType, predicate: NSPredicate, completion: @escaping (Result<[HKSample], Error>) -> Void ) { let query = HKSampleQuery(sampleType: sampleType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, results, error in if let error = error { completion(.failure(error)) return } guard let samples = results else { completion(.failure(HealthManagerError.noDataFound(sampleType.identifier))) return } completion(.success(samples)) } healthStore.execute(query) } } // MARK: - Fetch Methods extension HealthManager { /// Fetches the active calories burned today func fetchTodayCaloriesBurned(completion: @escaping (Result) -> Void) { do { try ensureHealthDataAvailable() let quantityType = HKQuantityType(.activeEnergyBurned) let predicate = HKQuery.predicateForSamples(withStart: .startOfDay, end: Date()) executeStatisticsQuery(quantityType: quantityType, predicate: predicate, unit: .kilocalorie(), completion: completion) } catch { completion(.failure(error)) } } /// Fetches the exercise time today func fetchTodayExerciseTime(completion: @escaping (Result) -> Void) { do { try ensureHealthDataAvailable() let quantityType = HKQuantityType(.appleExerciseTime) let predicate = HKQuery.predicateForSamples(withStart: .startOfDay, end: Date()) executeStatisticsQuery(quantityType: quantityType, predicate: predicate, unit: .minute(), completion: completion) } catch { completion(.failure(error)) } } /// Fetches the number of stand hours today func fetchTodayStandHours(completion: @escaping (Result) -> Void) { do { try ensureHealthDataAvailable() let categoryType = HKCategoryType(.appleStandHour) let predicate = HKQuery.predicateForSamples(withStart: .startOfDay, end: Date()) executeSampleQuery(sampleType: categoryType, predicate: predicate) { result in switch result { case .failure(let error): completion(.failure(error)) case .success(let samples): // Ensure samples are cast to HKCategorySample guard let categorySamples = samples as? [HKCategorySample] else { completion(.failure(HealthManagerError.noDataFound("stand hours"))) return } // Count stand hours let standHours = categorySamples.filter { $0.value == HKCategoryValueAppleStandHour.stood.rawValue }.count completion(.success(Double(standHours))) } } } catch { completion(.failure(error)) } } func fetchTodaySteps(completion: @escaping (Result) -> Void) { do { try ensureHealthDataAvailable() let quantityType = HKQuantityType(.stepCount) let predicate = HKQuery.predicateForSamples(withStart: .startOfDay, end: Date(), options: .strictStartDate) executeStatisticsQuery(quantityType: quantityType, predicate: predicate, unit: .count()) { result in switch result { case .failure(let error): completion(.failure(error)) case .success(let steps): let activity = Activity(title: "Today Steps", subtitle: "Goal: 800", image: "figure.walk", tintColor: .green, amount: steps.formattedNumberString()) completion(.success(activity)) } } } catch { completion(.failure(error)) } } func fetchCurrentWeekWorkoutStats(completion: @escaping (Result<[Activity], Error>) -> Void) { do { try ensureHealthDataAvailable() let workoutType = HKObjectType.workoutType() let predicate = HKQuery.predicateForSamples(withStart: .startOfWeek, end: Date()) executeSampleQuery(sampleType: workoutType, predicate: predicate) { result in switch result { case .failure(let error): completion(.failure(error)) case .success(let samples): guard let workouts = samples as? [HKWorkout] else { completion(.failure(HealthManagerError.noDataFound("workout stats for the current week"))) return } // Group and aggregate workouts by activity type var aggregatedWorkouts: [HKWorkoutActivityType: (duration: Double, calories: Double)] = [:] for workout in workouts { let activityType = workout.workoutActivityType let duration = workout.duration / 60 // Convert seconds to minutes let calories = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()) ?? 0.0 if aggregatedWorkouts[activityType] == nil { aggregatedWorkouts[activityType] = (duration: 0.0, calories: 0.0) } aggregatedWorkouts[activityType]?.duration += duration aggregatedWorkouts[activityType]?.calories += calories } // Convert aggregated results into Activity objects let activities: [Activity] = aggregatedWorkouts.map { activityType, totals in let durationString = "\(totals.duration.formattedNumberString()) min" let caloriesString = "\(totals.calories.formattedNumberString()) kcal" return Activity( title: activityType.readableName, subtitle: "This week: \(durationString)", image: activityType.image, tintColor: activityType.tintColor, amount: caloriesString ) } completion(.success(activities)) } } } catch { completion(.failure(error)) } } func fetchWorkoutsForMonth(month: Date, completion: @escaping (Result<[Workout], Error>) -> Void) { do { try ensureHealthDataAvailable() let workoutType = HKObjectType.workoutType() // Calculate the start and end dates for the specified month let calendar = Calendar.current guard let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: month)), let endOfMonth = calendar.date(byAdding: DateComponents(month: 1, day: -1), to: startOfMonth) else { completion(.failure(HealthManagerError.noDataFound("Invalid month date range."))) return } let predicate = HKQuery.predicateForSamples(withStart: startOfMonth, end: endOfMonth) executeSampleQuery(sampleType: workoutType, predicate: predicate) { result in switch result { case .failure(let error): completion(.failure(error)) case .success(let samples): guard let workouts = samples as? [HKWorkout] else { completion(.failure(HealthManagerError.noDataFound("workouts for the specified month."))) return } // Convert workouts into your Workout model (if needed) let mappedWorkouts: [Workout] = workouts.map { workout in Workout(title: workout.workoutActivityType.readableName, image: workout.workoutActivityType.image, tintColor: workout.workoutActivityType.tintColor, duration: "\((workout.duration / 60).formattedNumberString())", date: workout.startDate.formatted(date: .abbreviated, time: .omitted), calories: "\((workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()) ?? 0.0).formattedNumberString())") } completion(.success(mappedWorkouts)) } } } catch { completion(.failure(error)) } } } extension HKWorkoutActivityType { var readableName: String { switch self { case .americanFootball: return "American Football" case .archery: return "Archery" case .australianFootball: return "Australian Football" case .badminton: return "Badminton" case .baseball: return "Baseball" case .basketball: return "Basketball" case .bowling: return "Bowling" case .boxing: return "Boxing" case .climbing: return "Climbing" case .cricket: return "Cricket" case .crossTraining: return "Cross Training" case .curling: return "Curling" case .cycling: return "Cycling" case .dance: return "Dance" case .elliptical: return "Elliptical" case .equestrianSports: return "Equestrian Sports" case .fencing: return "Fencing" case .fishing: return "Fishing" case .functionalStrengthTraining: return "Functional Strength Training" case .golf: return "Golf" case .gymnastics: return "Gymnastics" case .handball: return "Handball" case .hiking: return "Hiking" case .hockey: return "Hockey" case .lacrosse: return "Lacrosse" case .martialArts: return "Martial Arts" case .mindAndBody: return "Mind and Body" case .paddleSports: return "Paddle Sports" case .play: return "Play" case .preparationAndRecovery: return "Preparation and Recovery" case .racquetball: return "Racquetball" case .rowing: return "Rowing" case .rugby: return "Rugby" case .running: return "Running" case .sailing: return "Sailing" case .skatingSports: return "Skating Sports" case .snowSports: return "Snow Sports" case .soccer: return "Soccer" case .softball: return "Softball" case .squash: return "Squash" case .stairClimbing: return "Stair Climbing" case .surfingSports: return "Surfing Sports" case .swimming: return "Swimming" case .tableTennis: return "Table Tennis" case .tennis: return "Tennis" case .trackAndField: return "Track and Field" case .traditionalStrengthTraining: return "Traditional Strength Training" case .volleyball: return "Volleyball" case .walking: return "Walking" case .waterFitness: return "Water Fitness" case .waterPolo: return "Water Polo" case .waterSports: return "Water Sports" case .wrestling: return "Wrestling" case .yoga: return "Yoga" case .barre: return "Barre" case .coreTraining: return "Core Training" case .crossCountrySkiing: return "Cross Country Skiing" case .downhillSkiing: return "Downhill Skiing" case .flexibility: return "Flexibility" case .highIntensityIntervalTraining: return "High-Intensity Interval Training (HIIT)" case .jumpRope: return "Jump Rope" case .kickboxing: return "Kickboxing" case .pilates: return "Pilates" case .snowboarding: return "Snowboarding" case .stairs: return "Stairs" case .stepTraining: return "Step Training" case .wheelchairWalkPace: return "Wheelchair Walk Pace" case .wheelchairRunPace: return "Wheelchair Run Pace" case .taiChi: return "Tai Chi" case .mixedCardio: return "Mixed Cardio" case .handCycling: return "Hand Cycling" case .discSports: return "Disc Sports" case .fitnessGaming: return "Fitness Gaming" case .danceInspiredTraining: return "Dance-Inspired Training" case .hunting: return "Hunting" case .mixedMetabolicCardioTraining: return "Mixed Metabolic Cardio Training" case .cardioDance: return "Cardio Dance" case .socialDance: return "Social Dance" case .pickleball: return "Pickleball" case .cooldown: return "Cooldown" case .swimBikeRun: return "Swim Bike Run" case .transition: return "Transition" case .underwaterDiving: return "Underwater Diving" case .other: return "Other" @unknown default: return "Unknown" } } var image: String { switch self { case .americanFootball: return "sportscourt.fill" case .archery: return "scope" case .australianFootball: return "sportscourt" case .badminton: return "figure.badminton" case .baseball: return "baseball.fill" case .basketball: return "sportscourt" case .bowling: return "bowl" case .boxing: return "boxing.glove.fill" case .climbing: return "figure.climbing" case .cricket: return "figure.cricket" case .crossTraining: return "figure.cross.training" case .curling: return "curling.stone" case .cycling: return "bicycle" case .dance: return "figure.dance" case .elliptical: return "figure.elliptical" case .equestrianSports: return "figure.equestrian.sports" case .fencing: return "swords" case .fishing: return "fish" case .functionalStrengthTraining: return "figure.strengthtraining.functional" case .golf: return "flag" case .gymnastics: return "figure.gymnastics" case .handball: return "sportscourt" case .hiking: return "figure.hiking" case .hockey: return "figure.hockey" case .lacrosse: return "figure.lacrosse" case .martialArts: return "figure.martial.arts" case .mindAndBody: return "figure.mind.and.body" case .paddleSports: return "figure.paddle.sports" case .play: return "figure.play" case .preparationAndRecovery: return "figure.preparation.recovery" case .racquetball: return "figure.racquetball" case .rowing: return "figure.rowing" case .rugby: return "figure.rugby" case .running: return "figure.run" case .sailing: return "sailboat.fill" case .skatingSports: return "figure.skating" case .snowSports: return "snowflake" case .soccer: return "soccerball" case .softball: return "softball.fill" case .squash: return "figure.squash" case .stairClimbing: return "figure.stair.climbing" case .surfingSports: return "figure.surfing" case .swimming: return "figure.swimming" case .tableTennis: return "figure.table.tennis" case .tennis: return "tennis.racket" case .trackAndField: return "sportscourt" case .traditionalStrengthTraining: return "figure.strengthtraining.traditional" case .volleyball: return "figure.volleyball" case .walking: return "figure.walk" case .waterFitness: return "figure.water.fitness" case .waterPolo: return "figure.water.polo" case .waterSports: return "figure.water.sports" case .wrestling: return "figure.wrestling" case .yoga: return "figure.yoga" case .barre: return "figure.barre" case .coreTraining: return "figure.core.training" case .crossCountrySkiing: return "figure.cross.country.skiing" case .downhillSkiing: return "figure.downhill.skiing" case .flexibility: return "figure.flexibility" case .highIntensityIntervalTraining: return "figure.highintensity.intervaltraining" case .jumpRope: return "figure.jump.rope" case .kickboxing: return "figure.kickboxing" case .pilates: return "figure.pilates" case .snowboarding: return "figure.snowboarding" case .stairs: return "figure.stairs" case .stepTraining: return "figure.step.training" case .wheelchairWalkPace: return "figure.wheelchair.walk" case .wheelchairRunPace: return "figure.wheelchair.run" case .taiChi: return "figure.taichi" case .mixedCardio: return "figure.mixed.cardio" case .handCycling: return "figure.hand.cycling" case .discSports: return "figure.disc.sports" case .fitnessGaming: return "figure.fitness.gaming" case .danceInspiredTraining: return "figure.dance" case .hunting: return "figure.hunting" case .mixedMetabolicCardioTraining: return "figure.mixed.cardio" case .cardioDance: return "figure.dance" case .socialDance: return "figure.dance" case .pickleball: return "figure.pickleball" case .cooldown: return "figure.cooldown" case .swimBikeRun: return "figure.swim.bike.run" case .transition: return "figure.transition" case .underwaterDiving: return "figure.underwater.diving" case .other: return "ellipsis.circle" @unknown default: return "questionmark.circle" } } var tintColor: Color { switch self { case .americanFootball: return .brown case .archery: return .blue case .australianFootball: return .orange case .badminton: return .green case .baseball: return .blue case .basketball: return .orange case .bowling: return .purple case .boxing: return .red case .climbing: return .brown case .cricket: return .green case .crossTraining: return .yellow case .curling: return .cyan case .cycling: return .teal case .dance: return .pink case .elliptical: return .gray case .equestrianSports: return .brown case .fencing: return .gray case .fishing: return .blue case .functionalStrengthTraining: return .indigo case .golf: return .green case .gymnastics: return .purple case .handball: return .red case .hiking: return .green case .hockey: return .indigo case .lacrosse: return .pink case .martialArts: return .red case .mindAndBody: return .purple case .paddleSports: return .blue case .play: return .yellow case .preparationAndRecovery: return .gray case .racquetball: return .teal case .rowing: return .blue case .rugby: return .brown case .running: return .red case .sailing: return .blue case .skatingSports: return .cyan case .snowSports: return .white case .soccer: return .green case .softball: return .orange case .squash: return .purple case .stairClimbing: return .gray case .surfingSports: return .blue case .swimming: return .cyan case .tableTennis: return .orange case .tennis: return .green case .trackAndField: return .yellow case .traditionalStrengthTraining: return .gray case .volleyball: return .orange case .walking: return .green case .waterFitness: return .blue case .waterPolo: return .blue case .waterSports: return .cyan case .wrestling: return .red case .yoga: return .purple case .barre: return .pink case .coreTraining: return .gray case .crossCountrySkiing: return .cyan case .downhillSkiing: return .white case .flexibility: return .purple case .highIntensityIntervalTraining: return .red case .jumpRope: return .orange case .kickboxing: return .red case .pilates: return .purple case .snowboarding: return .cyan case .stairs: return .gray case .stepTraining: return .orange case .wheelchairWalkPace: return .green case .wheelchairRunPace: return .red case .taiChi: return .blue case .mixedCardio: return .yellow case .handCycling: return .teal case .discSports: return .orange case .fitnessGaming: return .purple case .danceInspiredTraining: return .pink case .hunting: return .brown case .mixedMetabolicCardioTraining: return .yellow case .cardioDance: return .pink case .socialDance: return .pink case .pickleball: return .green case .cooldown: return .gray case .swimBikeRun: return .blue case .transition: return .gray case .underwaterDiving: return .cyan case .other: return .gray @unknown default: return .gray } } }