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) } }