feat: Full redesign — glassmorphism, weekly charts, HealthKit sync, toast notifications

- DashboardView: полный редизайн с приветствием по времени суток, pull-to-refresh
- ReadinessCardView: анимированное кольцо, цветные факторы с иконками
- MetricCardView: glassmorphism карточки, градиентные иконки, SleepCard/StepsCard
- WeeklyChartView: bar chart (Sleep/HRV/Steps) без внешних библиотек
- ToastView: уведомления об успехе/ошибке с автоскрытием
- HealthKitService: полный сбор метрик + отправка на сервер
- HealthModels: HeatmapEntry для недельных данных
- Тёмная тема #0a0a1a, haptic feedback, анимации появления
This commit is contained in:
Cosmo
2026-03-25 11:13:20 +00:00
parent 14fcf7f770
commit 1eafeec5fe
8 changed files with 1230 additions and 105 deletions

View File

@@ -1,32 +1,212 @@
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 {
let typesToRead: Set<HKObjectType> = [
HKQuantityType(.heartRate),
HKQuantityType(.restingHeartRate),
HKQuantityType(.heartRateVariabilitySDNN),
HKQuantityType(.stepCount),
HKQuantityType(.activeEnergyBurned),
HKQuantityType(.oxygenSaturation),
HKCategoryType(.sleepAnalysis),
]
try await healthStore.requestAuthorization(toShare: [], read: typesToRead)
}
func fetchTodaySteps() async -> Int {
guard let type = HKQuantityType.quantityType(forIdentifier: .stepCount) else { return 0 }
// 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)
return await withCheckedContinuation { cont in
let q = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, _ in
cont.resume(returning: Int(result?.sumQuantity()?.doubleValue(for: .count()) ?? 0))
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))]
}
healthStore.execute(q)
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 = "\(APIService.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)"
}
}
}