Files
KisaniCal/KisaniCal/Managers/HealthKitManager.swift
kutesir c75a067993 fix(healthkit): use let for interval captured across async let tasks
Build error (Xcode, Swift 6): 'interval' was a var built via two statements
then captured by 3 concurrent async let tasks in dailyReference — Swift 6
requires captured values to be immutable. It was never mutated after setup;
switched to a single-expression let (DateComponents(day: 1)).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00

225 lines
9.8 KiB
Swift

import HealthKit
import SwiftUI
@MainActor
final class HealthKitManager: ObservableObject {
static let shared = HealthKitManager()
private let store = HKHealthStore()
@Published var authorized = false
@Published var stepsToday: Int = 0
@Published var activeCaloriesToday: Int = 0
@Published var restingHeartRate: Int = 0
@Published var workoutsThisWeek: Int = 0
var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
// Persists across launches: HealthKit can't report read-authorization status,
// so we remember that the user opted in and re-establish on launch.
private let connectedKey = "kisani.health.connected"
var isConnected: Bool { UserDefaults.kisani.bool(forKey: connectedKey) }
private init() {}
// MARK: - Auth
func requestAuthorization() async -> Bool {
guard isAvailable else { return false }
do {
try await store.requestAuthorization(toShare: shareTypes, read: readTypes)
authorized = true
UserDefaults.kisani.set(true, forKey: connectedKey)
await refresh()
return true
} catch {
return false
}
}
/// Re-establish authorization on app launch if the user previously connected Health.
func bootstrap() {
guard isAvailable, isConnected else { return }
authorized = true
Task { await refresh() }
}
/// Turn the integration off: hides the dashboard and stops Health reads.
func disconnect() {
authorized = false
UserDefaults.kisani.set(false, forKey: connectedKey)
stepsToday = 0
activeCaloriesToday = 0
restingHeartRate = 0
workoutsThisWeek = 0
}
// MARK: - Refresh all stats
func refresh() async {
await withTaskGroup(of: Void.self) { g in
g.addTask { await self.fetchSteps() }
g.addTask { await self.fetchCalories() }
g.addTask { await self.fetchHeartRate() }
g.addTask { await self.fetchWorkoutsThisWeek() }
}
}
// MARK: - Read workout dates from Health (for streak sync)
func fetchWorkoutDates(from start: Date, to end: Date) async -> [String] {
guard authorized, isAvailable else { return [] }
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
let cal = Calendar.current
let datePred = HKQuery.predicateForSamples(withStart: start, end: end)
let durationPred = HKQuery.predicateForWorkouts(with: .greaterThan, duration: 60)
let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [datePred, durationPred])
let workouts: [HKWorkout] = await withCheckedContinuation { cont in
let q = HKSampleQuery(sampleType: HKObjectType.workoutType(),
predicate: pred, limit: HKObjectQueryNoLimit,
sortDescriptors: nil) { _, samples, _ in
cont.resume(returning: (samples as? [HKWorkout]) ?? [])
}
store.execute(q)
}
let dates = Set(workouts.map { w -> String in
fmt.string(from: cal.startOfDay(for: w.startDate))
})
return Array(dates)
}
// MARK: - Save workout to Health
func saveWorkout(start: Date, end: Date) async {
guard authorized, isAvailable else { return }
let workout = HKWorkout(
activityType: .traditionalStrengthTraining,
start: start,
end: end
)
do { try await store.save(workout) } catch {}
}
// MARK: - Fetch helpers
private func fetchSteps() async {
guard let type = HKQuantityType.quantityType(forIdentifier: .stepCount) else { return }
let start = Calendar.current.startOfDay(for: Date())
let pred = HKQuery.predicateForSamples(withStart: start, end: Date())
let val = await sumQuery(type: type, unit: .count(), predicate: pred)
stepsToday = Int(val)
}
private func fetchCalories() async {
guard let type = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) else { return }
let start = Calendar.current.startOfDay(for: Date())
let pred = HKQuery.predicateForSamples(withStart: start, end: Date())
let val = await sumQuery(type: type, unit: .kilocalorie(), predicate: pred)
activeCaloriesToday = Int(val)
}
private func fetchHeartRate() async {
guard let type = HKQuantityType.quantityType(forIdentifier: .restingHeartRate) else { return }
let val = await latestQuery(type: type, unit: HKUnit(from: "count/min"))
restingHeartRate = Int(val)
}
private func fetchWorkoutsThisWeek() async {
let cal = Calendar.current
guard let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date())) else { return }
let datePred = HKQuery.predicateForSamples(withStart: weekStart, end: Date())
let durationPred = HKQuery.predicateForWorkouts(with: .greaterThan, duration: 0)
let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [datePred, durationPred])
let count: Int = await withCheckedContinuation { cont in
let q = HKSampleQuery(sampleType: HKObjectType.workoutType(), predicate: pred,
limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, _ in
cont.resume(returning: samples?.count ?? 0)
}
store.execute(q)
}
workoutsThisWeek = count
}
private func sumQuery(type: HKQuantityType, unit: HKUnit, predicate: NSPredicate) async -> Double {
await withCheckedContinuation { cont in
let q = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, stats, _ in
cont.resume(returning: stats?.sumQuantity()?.doubleValue(for: unit) ?? 0)
}
store.execute(q)
}
}
private func latestQuery(type: HKQuantityType, unit: HKUnit) async -> Double {
await withCheckedContinuation { cont in
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
let q = HKSampleQuery(sampleType: type, predicate: nil, limit: 1, sortDescriptors: [sort]) { _, samples, _ in
let val = (samples?.first as? HKQuantitySample)?.quantity.doubleValue(for: unit) ?? 0
cont.resume(returning: val)
}
store.execute(q)
}
}
// MARK: - Historical daily reference (for analytics)
/// Per-day steps / active calories / resting heart rate over a range, keyed by
/// "yyyy-MM-dd". One bucketed statistics query per metric (efficient over a
/// year). HealthKit is the source of truth; values are read-only reference.
func dailyReference(from start: Date, to end: Date) async -> [String: (steps: Int?, calories: Int?, restingHR: Int?)] {
guard authorized, isAvailable else { return [:] }
let anchor = Calendar.current.startOfDay(for: start)
let interval = DateComponents(day: 1)
async let steps = statsCollection(.stepCount, unit: .count(), options: .cumulativeSum, anchor: anchor, end: end, interval: interval)
async let cals = statsCollection(.activeEnergyBurned, unit: .kilocalorie(), options: .cumulativeSum, anchor: anchor, end: end, interval: interval)
async let hr = statsCollection(.restingHeartRate, unit: HKUnit(from: "count/min"), options: .discreteAverage, anchor: anchor, end: end, interval: interval)
let (s, c, h) = await (steps, cals, hr)
var out: [String: (steps: Int?, calories: Int?, restingHR: Int?)] = [:]
for key in Set(s.keys).union(c.keys).union(h.keys) {
out[key] = (s[key].map(Int.init), c[key].map(Int.init), h[key].map(Int.init))
}
return out
}
private func statsCollection(_ id: HKQuantityTypeIdentifier, unit: HKUnit, options: HKStatisticsOptions,
anchor: Date, end: Date, interval: DateComponents) async -> [String: Double] {
guard let type = HKQuantityType.quantityType(forIdentifier: id) else { return [:] }
return await withCheckedContinuation { cont in
let q = HKStatisticsCollectionQuery(quantityType: type, quantitySamplePredicate: nil,
options: options, anchorDate: anchor, intervalComponents: interval)
q.initialResultsHandler = { _, results, _ in
var dict: [String: Double] = [:]
let fmt = DateFormatter()
fmt.calendar = Calendar.current; fmt.timeZone = Calendar.current.timeZone
fmt.locale = Locale(identifier: "en_US_POSIX"); fmt.dateFormat = "yyyy-MM-dd"
results?.enumerateStatistics(from: anchor, to: end) { stat, _ in
let quantity = options.contains(.cumulativeSum) ? stat.sumQuantity() : stat.averageQuantity()
if let v = quantity?.doubleValue(for: unit), v > 0 {
dict[fmt.string(from: stat.startDate)] = v
}
}
cont.resume(returning: dict)
}
store.execute(q)
}
}
// MARK: - Type sets
private var readTypes: Set<HKObjectType> {
let ids: [HKQuantityTypeIdentifier] = [
.stepCount, .activeEnergyBurned, .restingHeartRate, .bodyMass, .height
]
var set: Set<HKObjectType> = Set(ids.compactMap { HKQuantityType.quantityType(forIdentifier: $0) })
set.insert(HKObjectType.workoutType())
return set
}
private var shareTypes: Set<HKSampleType> {
var set: Set<HKSampleType> = [HKObjectType.workoutType()]
if let bm = HKQuantityType.quantityType(forIdentifier: .bodyMass) { set.insert(bm) }
return set
}
}