import HealthKit import Foundation @MainActor 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), HKQuantityType(.respiratoryRate), HKCategoryType(.sleepAnalysis), ] func requestAuthorization() async throws { guard isAvailable else { throw HealthKitError.notAvailable } try await healthStore.requestAuthorization(toShare: [], read: typesToRead) } func checkAuthorization(for type: HKObjectType) -> Bool { healthStore.authorizationStatus(for: type) == .sharingAuthorized } // 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]] ]) } // Respiratory Rate (дыхание) let rrValues = await fetchAllSamples(.respiratoryRate, unit: HKUnit.count().unitDivided(by: .minute()), predicate: predicate) if !rrValues.isEmpty { let rrData = rrValues.map { sample -> [String: Any] in ["date": dateFormatter.string(from: sample.startDate), "qty": sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute()))] } metrics.append([ "name": "respiratory_rate", "units": "breaths/min", "data": rrData ]) } // Sleep Analysis — расширенный диапазон (последние 24 ча��а, чтобы захватить ночной сон) let sleepData = await fetchSleepData(dateFormatter: dateFormatter) if !sleepData.isEmpty { metrics.append([ "name": "sleep_analysis", "units": "hr", "data": sleepData ]) } return metrics } // MARK: - Sleep Data Collection private func fetchSleepData(dateFormatter: DateFormatter) async -> [[String: Any]] { guard let sleepType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) else { return [] } // Ночной сон: с 18:00 вчера до сейчас (захватывает засыпание вечером + пробуждение утром) let now = Date() let cal = Calendar.current var startComponents = cal.dateComponents([.year, .month, .day], from: now) startComponents.hour = 18 startComponents.minute = 0 guard let todayEvening = cal.date(from: startComponents), let yesterdayEvening = cal.date(byAdding: .day, value: -1, to: todayEvening) else { return [] } let sleepStart = now.timeIntervalSince(todayEvening) > 0 ? todayEvening : yesterdayEvening let sleepPredicate = HKQuery.predicateForSamples(withStart: sleepStart, end: now) return await withCheckedContinuation { cont in let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true) let query = HKSampleQuery(sampleType: sleepType, predicate: sleepPredicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { _, samples, _ in guard let samples = samples as? [HKCategorySample], !samples.isEmpty else { cont.resume(returning: []) return } // Считаем время по каждой стадии сна в часах var deepSeconds = 0.0 var remSeconds = 0.0 var coreSeconds = 0.0 var awakeSeconds = 0.0 var asleepSeconds = 0.0 var inBedStart: Date? var inBedEnd: Date? for sample in samples { let duration = sample.endDate.timeIntervalSince(sample.startDate) switch sample.value { case HKCategoryValueSleepAnalysis.inBed.rawValue: if inBedStart == nil || sample.startDate < inBedStart! { inBedStart = sample.startDate } if inBedEnd == nil || sample.endDate > inBedEnd! { inBedEnd = sample.endDate } case HKCategoryValueSleepAnalysis.asleepDeep.rawValue: deepSeconds += duration asleepSeconds += duration case HKCategoryValueSleepAnalysis.asleepREM.rawValue: remSeconds += duration asleepSeconds += duration case HKCategoryValueSleepAnalysis.asleepCore.rawValue: coreSeconds += duration asleepSeconds += duration case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue: asleepSeconds += duration case HKCategoryValueSleepAnalysis.awake.rawValue: awakeSeconds += duration default: break } } // Если нет inBed, берём из asleep-сэмплов if inBedStart == nil { let asleepSamples = samples.filter { $0.value != HKCategoryValueSleepAnalysis.awake.rawValue && $0.value != HKCategoryValueSleepAnalysis.inBed.rawValue } inBedStart = asleepSamples.first?.startDate inBedEnd = asleepSamples.last?.endDate } let totalSleep = asleepSeconds / 3600.0 guard totalSleep > 0 else { cont.resume(returning: []) return } let startOfDay = Calendar.current.startOfDay(for: now) // Формат, который ожидает health-webhook API var entry: [String: Any] = [ "date": dateFormatter.string(from: startOfDay), "totalSleep": round(totalSleep * 1000) / 1000, "deep": round((deepSeconds / 3600.0) * 1000) / 1000, "rem": round((remSeconds / 3600.0) * 1000) / 1000, "core": round((coreSeconds / 3600.0) * 1000) / 1000, "awake": round((awakeSeconds / 3600.0) * 1000) / 1000, "asleep": round(totalSleep * 1000) / 1000, "source": "Apple Watch" ] if let start = inBedStart { entry["inBedStart"] = dateFormatter.string(from: start) entry["sleepStart"] = dateFormatter.string(from: start) } if let end = inBedEnd { entry["inBedEnd"] = dateFormatter.string(from: end) entry["sleepEnd"] = dateFormatter.string(from: end) } cont.resume(returning: [entry]) } self.healthStore.execute(query) } } // MARK: - Send to Server func syncToServer(apiKey: String) async throws { isSyncing = true defer { isSyncing = false } guard isAvailable else { throw HealthKitError.notAvailable } 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) guard let url = URL(string: "\(HealthAPIService.shared.baseURL)/api/health") else { throw HealthKitError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(apiKey, forHTTPHeaderField: "X-API-Key") request.httpBody = jsonData request.timeoutInterval = 30 let (data, 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 let body = String(data: data, encoding: .utf8) ?? "" throw HealthKitError.serverError(code, body) } } // 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: - Sleep Segments (timeline) func fetchSleepSegments() async -> [SleepSegment] { guard let sleepType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) else { return [] } let now = Date() let cal = Calendar.current var startComp = cal.dateComponents([.year, .month, .day], from: now) startComp.hour = 18 guard let todayEvening = cal.date(from: startComp), let yesterdayEvening = cal.date(byAdding: .day, value: -1, to: todayEvening) else { return [] } let sleepStart = now.timeIntervalSince(todayEvening) > 0 ? todayEvening : yesterdayEvening let predicate = HKQuery.predicateForSamples(withStart: sleepStart, end: now) return await withCheckedContinuation { cont in let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true) let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { _, samples, _ in guard let samples = samples as? [HKCategorySample] else { cont.resume(returning: []) return } let segments: [SleepSegment] = samples.compactMap { sample in let phase: SleepPhaseType? switch sample.value { case HKCategoryValueSleepAnalysis.asleepDeep.rawValue: phase = .deep case HKCategoryValueSleepAnalysis.asleepREM.rawValue: phase = .rem case HKCategoryValueSleepAnalysis.asleepCore.rawValue: phase = .core case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue: phase = .core case HKCategoryValueSleepAnalysis.awake.rawValue: phase = .awake default: phase = nil } guard let p = phase else { return nil } return SleepSegment(phase: p, start: sample.startDate, end: sample.endDate) } cont.resume(returning: segments) } self.healthStore.execute(query) } } } // MARK: - Errors enum HealthKitError: Error, LocalizedError { case notAvailable case noData case invalidURL case serverError(Int, String) var errorDescription: String? { switch self { case .notAvailable: return "HealthKit недоступен на этом устройстве" case .noData: return "Нет данных HealthKit за сегодня. Убедитесь, что Apple Watch синхронизированы" case .invalidURL: return "Неверный URL сервера" case .serverError(let code, let body): return "Ошибка сервера (\(code)): \(body.prefix(100))" } } }