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:
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user