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>
225 lines
9.8 KiB
Swift
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
|
|
}
|
|
}
|