Widgets (new KisaniCalWidgets extension) - Event Countdown widget: configurable via AppIntent, pick from your events or set a custom name/date; dot-grid progress toward the event. - Day Progress and Tasks-Done-Today widgets; lock screen widgets. - Shared dot grid sizes circles to fill the widget evenly at any count. - Tasks/calendar prefs now persist to the App Group so widgets read live data. Health & streaks - Persist HealthKit authorization and re-establish on launch (fixes the Today dashboard and streak sync disappearing after relaunch). - Add a Health permission toggle to onboarding; unify Settings/Profile on the manager's state. - Day streak counts from a logged Health workout OR marking all exercises complete; nudge to mark exercises complete even when Health logged the workout. Workout editor - Drag to reorder exercises and move them across sections (drag-and-drop); swipe to delete. - Add-exercise sheet: global search and keep-adding-multiple with a running count. Fixes - Restore app entitlements link (Sign in with Apple, HealthKit, App Group, iCloud). - Add HealthKit usage strings; widget Info.plist NSExtension + nested bundle id. - Calendar "Connect" routes to Settings when access was denied. - Bake DEVELOPMENT_TEAM and shared versions into project.yml. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
180 lines
7.1 KiB
Swift
180 lines
7.1 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: - 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
|
|
}
|
|
}
|