Files
pulse-mobile/PulseHealth/Views/Health/HealthView.swift
Daniil Klimov a07696bd55 fix: widgets use App Group shared UserDefaults instead of Keychain
- Widgets can't access app's Keychain (different sandbox)
- App writes data to shared UserDefaults (group.com.daniil.pulsehealth)
- Widgets read from shared UserDefaults — no API calls needed
- WidgetDataService: updates widget data + reloads timelines
- DashboardView: pushes habits/tasks data to widget after load
- HealthView: pushes health data to widget after load
- App Group capability added to both app and widget entitlements
- Widgets update every 15 minutes from cached data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:47:26 +03:00

539 lines
25 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 SwiftUI
struct HealthView: View {
@EnvironmentObject var authManager: AuthManager
@StateObject private var healthKit = HealthKitService()
@State private var readiness: ReadinessResponse?
@State private var latest: LatestHealthResponse?
@State private var heatmapData: [HeatmapEntry] = []
@State private var isLoading = true
@State private var showToast = false
@State private var toastMessage = ""
@State private var toastSuccess = true
@State private var showSleepDetail = false
var dateString: String {
let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU"); f.dateFormat = "d MMMM, EEEE"
return f.string(from: Date())
}
var body: some View {
ZStack {
Color(hex: "06060f").ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
// MARK: - Header
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Здоровье").font(.title2.bold()).foregroundColor(.white)
Text(dateString).font(.subheadline).foregroundColor(Theme.textSecondary)
}
Spacer()
Button { Task { await syncHealthKit() } } label: {
ZStack {
Circle().fill(Color(hex: "1a1a3e")).frame(width: 42, height: 42)
if healthKit.isSyncing { ProgressView().tint(Theme.teal).scaleEffect(0.8) }
else { Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 16, weight: .medium)).foregroundColor(Theme.teal) }
}
}.disabled(healthKit.isSyncing)
}
.padding(.horizontal).padding(.top, 8)
if isLoading {
ProgressView().tint(Theme.teal).padding(.top, 60)
} else {
// MARK: - Readiness
if let r = readiness { ReadinessBanner(readiness: r) }
// MARK: - Core Metrics 2x2
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
Button { showSleepDetail = true } label: {
HealthMetricTile(icon: "moon.fill", title: "Сон",
value: String(format: "%.1f", latest?.sleep?.totalSleep ?? 0), unit: "ч",
status: sleepStatus, color: Theme.purple,
hint: "Норма 7-9ч. Нажми для деталей")
}.buttonStyle(.plain)
HealthMetricTile(icon: "heart.fill", title: "Пульс покоя",
value: "\(Int(latest?.restingHeartRate?.value ?? 0))", unit: "уд/м",
status: rhrStatus, color: Theme.red,
hint: "Чем ниже, тем лучше. Норма 50-70")
HealthMetricTile(icon: "waveform.path.ecg", title: "HRV",
value: "\(Int(latest?.hrv?.avg ?? 0))", unit: "мс",
status: hrvStatus, color: Theme.teal,
hint: "Вариабельность пульса. Выше = лучше")
HealthMetricTile(icon: "figure.walk", title: "Шаги",
value: fmtNum(latest?.steps?.total ?? 0), unit: "",
status: stepsStatus, color: Theme.orange,
hint: "Цель: 8 000 шагов в день")
}
.padding(.horizontal)
// MARK: - Secondary Metrics
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
if let spo2 = latest?.bloodOxygen, (spo2.avg ?? 0) > 0 {
HealthMetricTile(icon: "lungs.fill", title: "Кислород",
value: "\(Int(spo2.avg ?? 0))", unit: "%",
status: spo2Status, color: Theme.blue,
hint: "Насыщение крови O₂. Норма ≥ 96%")
}
if let rr = latest?.respiratoryRate, (rr.avg ?? 0) > 0 {
HealthMetricTile(icon: "wind", title: "Дыхание",
value: String(format: "%.0f", rr.avg ?? 0), unit: "вд/м",
status: rrStatus, color: Theme.indigo,
hint: "Частота дыхания. Норма 12-20")
}
if let energy = latest?.activeEnergy, (energy.total ?? 0) > 0 {
HealthMetricTile(icon: "flame.fill", title: "Энергия",
value: "\(energy.total ?? 0)", unit: energy.units == "kJ" ? "кДж" : "ккал",
status: energyStatus, color: Color(hex: "ff6348"),
hint: "Активные калории за день")
}
if let dist = latest?.distance, (dist.total ?? 0) > 0 {
HealthMetricTile(icon: "map.fill", title: "Дистанция",
value: String(format: "%.1f", (dist.total ?? 0) / 1000), unit: "км",
status: distStatus, color: Theme.green,
hint: "Пройдено пешком и бегом")
}
}
.padding(.horizontal)
// MARK: - Heart Rate Card
if let hr = latest?.heartRate, (hr.avg ?? 0) > 0 {
HeartRateCard(hr: hr, rhr: latest?.restingHeartRate)
}
// MARK: - Weekly Trends
if heatmapData.count >= 2 {
WeeklyTrendsCard(heatmapData: heatmapData)
}
// MARK: - Weekly Chart
if !heatmapData.isEmpty {
WeeklyChartCard(heatmapData: heatmapData)
}
// MARK: - Recovery Score
RecoveryCard(sleep: latest?.sleep, hrv: latest?.hrv, rhr: latest?.restingHeartRate)
// MARK: - Tips
TipsCard(readiness: readiness, latest: latest)
Spacer(minLength: 30)
}
}
}
.refreshable { await loadData(refresh: true) }
}
.toast(isShowing: $showToast, message: toastMessage, isSuccess: toastSuccess)
.sheet(isPresented: $showSleepDetail) {
if let sleep = latest?.sleep {
SleepDetailView(sleep: sleep).presentationDetents([.large]).presentationBackground(Color(hex: "06060f"))
}
}
.task {
if healthKit.isAvailable { try? await healthKit.requestAuthorization() }
await loadData()
}
}
// MARK: - Statuses
var sleepStatus: MetricStatus {
guard let s = latest?.sleep?.totalSleep, s > 0 else { return .noData }
if s >= 7.5 { return .good("Отличный сон") }
if s >= 6 { return .ok("Можно лучше") }
return .bad("Мало сна")
}
var rhrStatus: MetricStatus {
guard let v = latest?.restingHeartRate?.value, v > 0 else { return .noData }
if v <= 65 { return .good("Отлично") }
if v <= 80 { return .ok("Нормально") }
return .bad("Повышенный")
}
var hrvStatus: MetricStatus {
guard let v = latest?.hrv?.avg, v > 0 else { return .noData }
if v >= 50 { return .good("Хорошее восстановление") }
if v >= 30 { return .ok("Средний уровень") }
return .bad("Стресс / усталость")
}
var stepsStatus: MetricStatus {
guard let s = latest?.steps?.total, s > 0 else { return .noData }
if s >= 8000 { return .good("Цель достигнута") }
if s >= 5000 { return .ok("\(8000 - s) до цели") }
return .bad("Мало движения")
}
var spo2Status: MetricStatus {
guard let v = latest?.bloodOxygen?.avg, v > 0 else { return .noData }
if v >= 96 { return .good("Норма") }
if v >= 93 { return .ok("Пониженный") }
return .bad("Низкий!")
}
var rrStatus: MetricStatus {
guard let v = latest?.respiratoryRate?.avg, v > 0 else { return .noData }
if v >= 12 && v <= 20 { return .good("Норма") }
return .ok("Отклонение")
}
var energyStatus: MetricStatus {
guard let v = latest?.activeEnergy?.total, v > 0 else { return .noData }
if v >= 300 { return .good("Активный день") }
if v >= 150 { return .ok("Умеренно") }
return .bad("Мало активности")
}
var distStatus: MetricStatus {
guard let v = latest?.distance?.total, v > 0 else { return .noData }
let km = v / 1000
if km >= 5 { return .good("Отлично") }
if km >= 2 { return .ok("Нормально") }
return .bad("Мало")
}
// MARK: - Data
func loadData(refresh: Bool = false) async {
if !refresh { isLoading = true }
async let r = HealthAPIService.shared.getReadiness()
async let l = HealthAPIService.shared.getLatest()
async let h = HealthAPIService.shared.getHeatmap(days: 7)
readiness = try? await r; latest = try? await l; heatmapData = (try? await h) ?? []
isLoading = false
// Update widget
WidgetDataService.updateHealth(
steps: latest?.steps?.total ?? 0,
sleep: latest?.sleep?.totalSleep ?? 0,
heartRate: Int(latest?.restingHeartRate?.value ?? 0),
readiness: readiness?.score ?? 0
)
}
func syncHealthKit() async {
guard healthKit.isAvailable else { showToastMsg("HealthKit недоступен", success: false); return }
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
do {
try await healthKit.syncToServer(apiKey: authManager.healthApiKey)
UINotificationFeedbackGenerator().notificationOccurred(.success)
showToastMsg("Синхронизировано", success: true)
await loadData()
} catch {
UINotificationFeedbackGenerator().notificationOccurred(.error)
showToastMsg(error.localizedDescription, success: false)
}
}
private func showToastMsg(_ msg: String, success: Bool) {
toastMessage = msg; toastSuccess = success; withAnimation { showToast = true }
}
private func fmtNum(_ n: Int) -> String {
let f = NumberFormatter(); f.numberStyle = .decimal; f.groupingSeparator = " "
return f.string(from: NSNumber(value: n)) ?? "\(n)"
}
}
// MARK: - MetricStatus
enum MetricStatus {
case good(String), ok(String), bad(String), noData
var text: String {
switch self { case .good(let s), .ok(let s), .bad(let s): return s; case .noData: return "Нет данных" }
}
var color: Color {
switch self { case .good: return Theme.teal; case .ok: return Theme.orange; case .bad: return Theme.red; case .noData: return Theme.textSecondary }
}
var icon: String {
switch self { case .good: return "arrow.up.right"; case .ok: return "minus"; case .bad: return "arrow.down.right"; case .noData: return "questionmark" }
}
}
// MARK: - Readiness Banner
struct ReadinessBanner: View {
let readiness: ReadinessResponse
var statusColor: Color {
if readiness.score >= 80 { return Theme.teal }
if readiness.score >= 60 { return Theme.orange }
return Theme.red
}
var statusText: String {
if readiness.score >= 80 { return "Отличный день для активности" }
if readiness.score >= 60 { return "Умеренная нагрузка будет в самый раз" }
return "Лучше отдохнуть и восстановиться"
}
var body: some View {
HStack(spacing: 16) {
ZStack {
Circle().stroke(Color.white.opacity(0.08), lineWidth: 8).frame(width: 72, height: 72)
Circle().trim(from: 0, to: CGFloat(readiness.score) / 100)
.stroke(statusColor, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.frame(width: 72, height: 72).rotationEffect(.degrees(-90))
.shadow(color: statusColor.opacity(0.4), radius: 6)
Text("\(readiness.score)").font(.system(size: 22, weight: .bold, design: .rounded)).foregroundColor(statusColor)
}
VStack(alignment: .leading, spacing: 6) {
Text("Готовность").font(.subheadline).foregroundColor(Theme.textSecondary)
Text(statusText).font(.callout.weight(.medium)).foregroundColor(.white).lineLimit(2)
}
Spacer()
}
.padding(16).glassCard(cornerRadius: 18).padding(.horizontal)
}
}
// MARK: - Health Metric Tile
struct HealthMetricTile: View {
let icon: String; let title: String; let value: String; let unit: String
let status: MetricStatus; let color: Color
var hint: String? = nil
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
GlowIcon(systemName: icon, color: color, size: 32, iconSize: .caption)
Spacer()
HStack(spacing: 3) {
Image(systemName: status.icon).font(.system(size: 9, weight: .bold))
Text(status.text).font(.system(size: 10, weight: .medium))
}.foregroundColor(status.color)
}
HStack(alignment: .firstTextBaseline, spacing: 2) {
Text(value).font(.title2.bold().monospacedDigit()).foregroundColor(.white)
Text(unit).font(.caption.bold()).foregroundColor(Theme.textSecondary)
}
Text(title).font(.caption).foregroundColor(Theme.textSecondary)
if let h = hint {
Text(h).font(.system(size: 9)).foregroundColor(Theme.textSecondary.opacity(0.7)).lineLimit(2)
}
}
.padding(14).frame(maxWidth: .infinity, alignment: .leading).frame(minHeight: 120)
.glassCard(cornerRadius: 18)
}
}
// MARK: - Heart Rate Card
struct HeartRateCard: View {
let hr: HeartRateData
let rhr: RestingHRData?
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 8) {
GradientIcon(icon: "heart.fill", colors: [Theme.red, Theme.pink])
Text("Пульс за день").font(.headline).foregroundColor(.white)
Spacer()
}
HStack(spacing: 0) {
HRStatBox(label: "Мин", value: "\(hr.min ?? 0)", color: Theme.teal)
Divider().frame(height: 40).background(Color.white.opacity(0.1))
HRStatBox(label: "Средний", value: "\(hr.avg ?? 0)", color: .white)
Divider().frame(height: 40).background(Color.white.opacity(0.1))
HRStatBox(label: "Макс", value: "\(hr.max ?? 0)", color: Theme.red)
}
.padding(12)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.04)))
if let rhr = rhr?.value, rhr > 0 {
HStack(spacing: 6) {
Circle().fill(Theme.purple).frame(width: 6, height: 6)
Text("Пульс покоя: \(Int(rhr)) уд/мин").font(.caption).foregroundColor(Theme.textSecondary)
Spacer()
Text(rhr <= 65 ? "Отлично" : rhr <= 80 ? "Норма" : "Высокий")
.font(.caption.bold()).foregroundColor(rhr <= 65 ? Theme.teal : rhr <= 80 ? Theme.orange : Theme.red)
}
}
}
.padding(16).glassCard(cornerRadius: 18).padding(.horizontal)
}
}
struct HRStatBox: View {
let label: String; let value: String; let color: Color
var body: some View {
VStack(spacing: 4) {
Text(value).font(.title3.bold().monospacedDigit()).foregroundColor(color)
Text(label).font(.caption2).foregroundColor(Theme.textSecondary)
}.frame(maxWidth: .infinity)
}
}
// MARK: - Weekly Trends Card
struct WeeklyTrendsCard: View {
let heatmapData: [HeatmapEntry]
var avgSleep: Double {
let vals = heatmapData.compactMap(\.sleep).filter { $0 > 0 }
return vals.isEmpty ? 0 : vals.reduce(0, +) / Double(vals.count)
}
var avgHRV: Double {
let vals = heatmapData.compactMap(\.hrv).filter { $0 > 0 }
return vals.isEmpty ? 0 : vals.reduce(0, +) / Double(vals.count)
}
var avgRHR: Double {
let vals = heatmapData.compactMap(\.rhr).filter { $0 > 0 }
return vals.isEmpty ? 0 : vals.reduce(0, +) / Double(vals.count)
}
var avgSteps: Int {
let vals = heatmapData.compactMap(\.steps).filter { $0 > 0 }
return vals.isEmpty ? 0 : vals.reduce(0, +) / vals.count
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 8) {
GradientIcon(icon: "chart.line.uptrend.xyaxis", colors: [Theme.indigo, Theme.blue])
Text("Средние за неделю").font(.headline).foregroundColor(.white)
Spacer()
}
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
TrendItem(icon: "moon.fill", label: "Сон", value: String(format: "%.1f ч", avgSleep), color: Theme.purple)
TrendItem(icon: "waveform.path.ecg", label: "HRV", value: "\(Int(avgHRV)) мс", color: Theme.teal)
TrendItem(icon: "heart.fill", label: "Пульс покоя", value: "\(Int(avgRHR)) уд/м", color: Theme.red)
TrendItem(icon: "figure.walk", label: "Шаги", value: "\(avgSteps)", color: Theme.orange)
}
}
.padding(16).glassCard(cornerRadius: 18).padding(.horizontal)
}
}
struct TrendItem: View {
let icon: String; let label: String; let value: String; let color: Color
var body: some View {
HStack(spacing: 10) {
Image(systemName: icon).font(.caption).foregroundColor(color).frame(width: 18)
VStack(alignment: .leading, spacing: 2) {
Text(value).font(.callout.bold().monospacedDigit()).foregroundColor(.white)
Text(label).font(.caption2).foregroundColor(Theme.textSecondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04)))
}
}
// MARK: - Recovery Card
struct RecoveryCard: View {
let sleep: SleepData?
let hrv: HRVData?
let rhr: RestingHRData?
var sleepScore: Double {
guard let s = sleep?.totalSleep, s > 0 else { return 0 }
return min(s / 8.0, 1.0) * 100
}
var hrvScore: Double {
guard let v = hrv?.avg, v > 0 else { return 0 }
return min(v / 60.0, 1.0) * 100
}
var rhrScore: Double {
guard let v = rhr?.value, v > 0 else { return 0 }
if v <= 55 { return 100 }
if v <= 65 { return 80 }
if v <= 75 { return 60 }
return max(40 - (v - 75), 10)
}
var recoveryScore: Int {
let scores = [sleepScore, hrvScore, rhrScore].filter { $0 > 0 }
guard !scores.isEmpty else { return 0 }
// Weighted: 40% sleep, 35% HRV, 25% RHR
let w = sleepScore * 0.4 + hrvScore * 0.35 + rhrScore * 0.25
return Int(w)
}
var recoveryColor: Color {
if recoveryScore >= 75 { return Theme.teal }
if recoveryScore >= 50 { return Theme.orange }
return Theme.red
}
var recoveryText: String {
if recoveryScore >= 75 { return "Организм хорошо восстановился" }
if recoveryScore >= 50 { return "Среднее восстановление" }
if recoveryScore > 0 { return "Тело ещё не восстановилось" }
return "Недостаточно данных"
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 8) {
GradientIcon(icon: "battery.100.bolt", colors: [Theme.teal, Theme.green])
Text("Восстановление").font(.headline).foregroundColor(.white)
Spacer()
Text("\(recoveryScore)%").font(.title3.bold()).foregroundColor(recoveryColor)
}
Text(recoveryText).font(.subheadline).foregroundColor(.white.opacity(0.7))
// Factor bars
VStack(spacing: 8) {
RecoveryFactor(name: "Сон", score: sleepScore, color: Theme.purple)
RecoveryFactor(name: "HRV", score: hrvScore, color: Theme.teal)
RecoveryFactor(name: "Пульс покоя", score: rhrScore, color: Theme.red)
}
}
.padding(16).glassCard(cornerRadius: 18).padding(.horizontal)
}
}
struct RecoveryFactor: View {
let name: String; let score: Double; let color: Color
var body: some View {
HStack(spacing: 10) {
Text(name).font(.caption).foregroundColor(Theme.textSecondary).frame(width: 80, alignment: .leading)
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.06))
RoundedRectangle(cornerRadius: 3)
.fill(color)
.frame(width: geo.size.width * CGFloat(score / 100))
.shadow(color: color.opacity(0.3), radius: 3)
}
}.frame(height: 6)
Text("\(Int(score))%").font(.caption.bold().monospacedDigit()).foregroundColor(.white.opacity(0.6)).frame(width: 32, alignment: .trailing)
}
}
}
// MARK: - Tips Card
struct TipsCard: View {
let readiness: ReadinessResponse?; let latest: LatestHealthResponse?
var tips: [(icon: String, text: String, color: Color)] {
var r: [(String, String, Color)] = []
if let s = readiness?.score {
if s >= 80 { r.append(("bolt.fill", "Высокая готовность — идеальный день для тренировки", Theme.teal)) }
else if s < 60 { r.append(("bed.double.fill", "Низкая готовность — сфокусируйся на восстановлении", Theme.red)) }
}
if let s = latest?.sleep?.totalSleep {
if s < 6 { r.append(("moon.zzz.fill", "Критически мало сна. Ложись раньше", Theme.purple)) }
else if s < 7 { r.append(("moon.fill", "Старайся спать 7-9 часов", Theme.purple)) }
}
if let v = latest?.hrv?.avg, v > 0, v < 30 { r.append(("exclamationmark.triangle.fill", "Низкий HRV — возможен стресс", Theme.orange)) }
if let s = latest?.steps?.total, s > 0, s < 5000 { r.append(("figure.walk", "15 минут прогулки улучшат самочувствие", Theme.orange)) }
if let spo2 = latest?.bloodOxygen?.avg, spo2 > 0, spo2 < 95 { r.append(("lungs.fill", "Кислород ниже нормы — дыши глубже", Theme.blue)) }
if r.isEmpty { r.append(("sparkles", "Все показатели в норме — так держать!", Theme.teal)) }
return r
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "lightbulb.fill").foregroundColor(Theme.orange)
Text("Рекомендации").font(.headline).foregroundColor(.white)
}
ForEach(Array(tips.enumerated()), id: \.offset) { _, tip in
HStack(alignment: .top, spacing: 10) {
Image(systemName: tip.icon).font(.caption).foregroundColor(tip.color).frame(width: 20).padding(.top, 2)
Text(tip.text).font(.subheadline).foregroundColor(.white.opacity(0.85)).lineLimit(3)
}
}
}
.padding(16).glassCard(cornerRadius: 18).padding(.horizontal)
}
}