import HealthKit import Foundation class HealthKitService: ObservableObject { let healthStore = HKHealthStore() @Published var isSyncing = false var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() } private let typesToRead: Set = [ 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)" } } }