Files
pulse-mobile/PulseHealth/Services/HealthKitService.swift
Cosmo c015824b36 feat: полноценное Pulse приложение с TabBar
- Auth: переключено на Pulse API (api.digital-home.site) вместо health
- TabBar: Главная, Задачи, Привычки, Здоровье, Финансы
- Models: TaskModels, HabitModels, FinanceModels, обновлённые AuthModels
- Services: APIService (Pulse API), HealthAPIService (health отдельно)
- Dashboard: обзор дня с задачами, привычками, readiness, балансом
- Tasks: список, фильтр, создание, выполнение, удаление
- Habits: список с прогресс-баром, отметка выполнения, стрики
- Health: бывший DashboardView, HealthKit sync через health API key
- Finance: баланс, список транзакций, добавление расхода/дохода
- Health данные через x-api-key вместо JWT токена health сервиса
2026-03-25 11:49:52 +00:00

213 lines
8.0 KiB
Swift

import HealthKit
import Foundation
class HealthKitService: ObservableObject {
let healthStore = HKHealthStore()
@Published var isSyncing = false
var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
private let typesToRead: Set<HKObjectType> = [
HKQuantityType(.heartRate),
HKQuantityType(.restingHeartRate),
HKQuantityType(.heartRateVariabilitySDNN),
HKQuantityType(.stepCount),
HKQuantityType(.activeEnergyBurned),
HKQuantityType(.oxygenSaturation),
HKQuantityType(.distanceWalkingRunning),
HKCategoryType(.sleepAnalysis),
]
func requestAuthorization() async throws {
try await healthStore.requestAuthorization(toShare: [], read: typesToRead)
}
// MARK: - Collect All Metrics
func collectAllMetrics() async -> [[String: Any]] {
let now = Date()
let startOfDay = Calendar.current.startOfDay(for: now)
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z"
let dateStr = dateFormatter.string(from: startOfDay)
var metrics: [[String: Any]] = []
// Step Count
let steps = await fetchCumulativeSum(.stepCount, unit: .count(), predicate: predicate)
if steps > 0 {
metrics.append([
"name": "step_count",
"units": "count",
"data": [["date": dateStr, "qty": steps]]
])
}
// Resting Heart Rate
let rhr = await fetchLatestQuantity(.restingHeartRate, unit: HKUnit.count().unitDivided(by: .minute()), predicate: predicate)
if rhr > 0 {
metrics.append([
"name": "resting_heart_rate",
"units": "bpm",
"data": [["date": dateStr, "qty": rhr]]
])
}
// HRV
let hrvValues = await fetchAllSamples(.heartRateVariabilitySDNN, unit: .secondUnit(with: .milli), predicate: predicate)
if !hrvValues.isEmpty {
let hrvData = hrvValues.map { sample -> [String: Any] in
["date": dateFormatter.string(from: sample.startDate), "qty": sample.quantity.doubleValue(for: .secondUnit(with: .milli))]
}
metrics.append([
"name": "heart_rate_variability",
"units": "ms",
"data": hrvData
])
}
// Heart Rate
let hrValues = await fetchAllSamples(.heartRate, unit: HKUnit.count().unitDivided(by: .minute()), predicate: predicate)
if !hrValues.isEmpty {
let hrData = hrValues.map { sample -> [String: Any] in
["date": dateFormatter.string(from: sample.startDate), "qty": sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute()))]
}
metrics.append([
"name": "heart_rate",
"units": "bpm",
"data": hrData
])
}
// Active Energy (convert kcal to kJ)
let energy = await fetchCumulativeSum(.activeEnergyBurned, unit: .kilocalorie(), predicate: predicate)
if energy > 0 {
let kJ = energy * 4.184
metrics.append([
"name": "active_energy",
"units": "kJ",
"data": [["date": dateStr, "qty": kJ]]
])
}
// Blood Oxygen
let spo2Values = await fetchAllSamples(.oxygenSaturation, unit: .percent(), predicate: predicate)
if !spo2Values.isEmpty {
let spo2Data = spo2Values.map { sample -> [String: Any] in
["date": dateFormatter.string(from: sample.startDate), "qty": sample.quantity.doubleValue(for: .percent()) * 100]
}
metrics.append([
"name": "blood_oxygen_saturation",
"units": "%",
"data": spo2Data
])
}
// Walking + Running Distance
let distance = await fetchCumulativeSum(.distanceWalkingRunning, unit: .meter(), predicate: predicate)
if distance > 0 {
metrics.append([
"name": "walking_running_distance",
"units": "m",
"data": [["date": dateStr, "qty": distance]]
])
}
return metrics
}
// MARK: - Send to Server
func syncToServer(apiKey: String) async throws {
await MainActor.run { isSyncing = true }
defer { Task { @MainActor in isSyncing = false } }
try await requestAuthorization()
let metrics = await collectAllMetrics()
guard !metrics.isEmpty else {
throw HealthKitError.noData
}
let payload: [String: Any] = [
"data": [
"metrics": metrics
]
]
let jsonData = try JSONSerialization.data(withJSONObject: payload)
let urlStr = "\(HealthAPIService.shared.baseURL)/api/health?key=\(apiKey)"
guard let url = URL(string: urlStr) else {
throw HealthKitError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = jsonData
request.timeoutInterval = 30
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
throw HealthKitError.serverError(code)
}
}
// MARK: - Helpers
private func fetchCumulativeSum(_ identifier: HKQuantityTypeIdentifier, unit: HKUnit, predicate: NSPredicate) async -> Double {
guard let type = HKQuantityType.quantityType(forIdentifier: identifier) else { return 0 }
return await withCheckedContinuation { cont in
let query = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, _ in
cont.resume(returning: result?.sumQuantity()?.doubleValue(for: unit) ?? 0)
}
healthStore.execute(query)
}
}
private func fetchLatestQuantity(_ identifier: HKQuantityTypeIdentifier, unit: HKUnit, predicate: NSPredicate) async -> Double {
guard let type = HKQuantityType.quantityType(forIdentifier: identifier) else { return 0 }
return await withCheckedContinuation { cont in
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
let query = HKSampleQuery(sampleType: type, predicate: predicate, limit: 1, sortDescriptors: [sort]) { _, samples, _ in
let value = (samples?.first as? HKQuantitySample)?.quantity.doubleValue(for: unit) ?? 0
cont.resume(returning: value)
}
healthStore.execute(query)
}
}
private func fetchAllSamples(_ identifier: HKQuantityTypeIdentifier, unit: HKUnit, predicate: NSPredicate) async -> [HKQuantitySample] {
guard let type = HKQuantityType.quantityType(forIdentifier: identifier) else { return [] }
return await withCheckedContinuation { cont in
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
let query = HKSampleQuery(sampleType: type, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { _, samples, _ in
cont.resume(returning: (samples as? [HKQuantitySample]) ?? [])
}
healthStore.execute(query)
}
}
}
// MARK: - Errors
enum HealthKitError: Error, LocalizedError {
case noData
case invalidURL
case serverError(Int)
var errorDescription: String? {
switch self {
case .noData: return "Нет данных HealthKit за сегодня"
case .invalidURL: return "Неверный URL сервера"
case .serverError(let code): return "Ошибка сервера: \(code)"
}
}
}