Files
pulse-mobile/PulseHealth/Services/HealthKitService.swift
Daniil Klimov 28fca1de89 feat: major app overhaul — API fixes, glassmorphism UI, health dashboard, notifications
API Integration:
- Fix logHabit: send "date" instead of "completed_at"
- Fix FinanceCategory: "icon" → "emoji" to match API
- Fix task priorities: remove level 4, keep 1-3 matching API
- Fix habit frequencies: map monthly/interval → "custom" for API
- Add token refresh (401 → auto retry with new token)
- Add proper error handling (remove try? in save functions, show errors in UI)
- Add date field to savings transactions
- Add MonthlyPaymentDetail and OverduePayment models
- Fix habit completedToday: compute on client from logs (API doesn't return it)
- Filter habits by day of week on client (daily/weekly/monthly/interval)

Design System (glassmorphism):
- New DesignSystem.swift: Theme colors, GlassCard modifier, GlowIcon, GlowStatCard
- Custom tab bar with per-tab glow colors (VStack layout, not ZStack overlay)
- Deep dark background #06060f across all views
- Glass cards with gradient fill + stroke throughout app
- App icon: glassmorphism style with teal glow

Health Dashboard:
- Compact ReadinessBanner with recommendation text
- 8 metric tiles: sleep, HR, HRV, steps, SpO2, respiratory rate, energy, distance
- Each tile with status indicator (good/ok/bad) and hint text
- Heart rate card (min/avg/max)
- Weekly trends card (averages)
- Recovery score (weighted: 40% sleep, 35% HRV, 25% RHR)
- Tips card with actionable recommendations
- Sleep detail view with hypnogram (step chart of phases)
- Sleep segments timeline from HealthKit (deep/rem/core/awake with exact times)
- Line chart replacing bar chart for weekly data
- Collect respiratory_rate and sleep phases with timestamps from HealthKit
- Background sync every ~30min via BGProcessingTask

Notifications:
- NotificationService for local push notifications
- Morning/evening reminders with native DatePicker (wheel)
- Payment reminders: 5 days, 1 day, and day-of for recurring savings
- Notification settings in Settings tab

UI Fixes:
- Fix color picker overflow: HStack → LazyVGrid 5 columns
- Fix sheet headers: shorter text, proper padding
- Fix task/habit toggle: separate tap zones (checkbox vs edit)
- Fix deprecated onChange syntax for iOS 17+
- Savings overview: real monthly payments and detailed overdues from API
- Settings: timezone as Menu picker, removed Telegram/server notifications sections
- All sheets use .presentationDetents([.large])

Config:
- project.yml: real DEVELOPMENT_TEAM, HealthKit + BackgroundModes capabilities
- Info.plist: BGTaskScheduler + UIBackgroundModes
- Assets.xcassets with AppIcon
- CLAUDE.md project documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:15:36 +03:00

373 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
HKQuantityType(.respiratoryRate),
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]]
])
}
// 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 ча<EFBFBD><EFBFBD>а, чтобы захватить ночной сон)
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 [] }
// Берём последние 24 часа, чтобы захватить ночной сон
let now = Date()
let yesterday = Calendar.current.date(byAdding: .hour, value: -24, to: now)!
let sleepPredicate = HKQuery.predicateForSamples(withStart: yesterday, 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 {
await MainActor.run { isSyncing = true }
defer { Task { @MainActor in 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)
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 (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 yesterday = Calendar.current.date(byAdding: .hour, value: -24, to: now)!
let predicate = HKQuery.predicateForSamples(withStart: yesterday, 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))"
}
}
}