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>
This commit is contained in:
2026-04-05 23:15:36 +03:00
parent 1146965bcb
commit 28fca1de89
38 changed files with 3608 additions and 1031 deletions

View File

@@ -8,212 +8,523 @@ struct HealthView: View {
@State private var latest: LatestHealthResponse?
@State private var heatmapData: [HeatmapEntry] = []
@State private var isLoading = true
// Toast state
@State private var showToast = false
@State private var toastMessage = ""
@State private var toastSuccess = true
var greeting: String {
let hour = Calendar.current.component(.hour, from: Date())
switch hour {
case 5..<12: return "Доброе утро"
case 12..<17: return "Добрый день"
case 17..<22: return "Добрый вечер"
default: return "Доброй ночи"
}
}
@State private var showSleepDetail = false
var dateString: String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "ru_RU")
formatter.dateFormat = "d MMMM, EEEE"
return formatter.string(from: Date())
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: "0a0a1a")
.ignoresSafeArea()
Color(hex: "06060f").ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
VStack(spacing: 16) {
// MARK: - Header
headerView
.padding(.top, 8)
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 {
loadingView
ProgressView().tint(Theme.teal).padding(.top, 60)
} else {
// MARK: - Readiness
if let r = readiness {
ReadinessCardView(readiness: r)
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: - Metrics Grid
metricsGrid
// MARK: - Weekly Trends
if heatmapData.count >= 2 {
WeeklyTrendsCard(heatmapData: heatmapData)
}
// MARK: - Weekly Chart
if !heatmapData.isEmpty {
WeeklyChartCard(heatmapData: heatmapData)
}
// MARK: - Insights
InsightsCard(readiness: readiness, latest: latest)
// 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)
}
.refreshable { await loadData(refresh: true) }
}
.toast(isShowing: $showToast, message: toastMessage, isSuccess: toastSuccess)
.task { await loadData() }
}
// MARK: - Header
private var headerView: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Здоровье 🫀")
.font(.title2.bold())
.foregroundColor(.white)
Text(dateString)
.font(.subheadline)
.foregroundColor(Color(hex: "8888aa"))
}
Spacer()
// Sync button
Button {
Task { await syncHealthKit() }
} label: {
ZStack {
Circle()
.fill(Color(hex: "1a1a3e"))
.frame(width: 42, height: 42)
if healthKit.isSyncing {
ProgressView()
.tint(Color(hex: "00d4aa"))
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color(hex: "00d4aa"))
}
}
}
.disabled(healthKit.isSyncing)
}
}
.padding(.horizontal)
}
// MARK: - Loading
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
.tint(Color(hex: "00d4aa"))
.scaleEffect(1.2)
Text("Загрузка данных...")
.font(.subheadline)
.foregroundColor(Color(hex: "8888aa"))
}
.padding(.top, 80)
}
// MARK: - Metrics Grid
private var metricsGrid: some View {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
.sheet(isPresented: $showSleepDetail) {
if let sleep = latest?.sleep {
SleepCard(sleep: sleep)
.frame(maxHeight: .infinity)
}
if let rhr = latest?.restingHeartRate {
MetricCardView(
icon: "heart.fill",
title: "Пульс покоя",
value: "\(Int(rhr.value ?? 0)) уд/мин",
subtitle: latest?.heartRate != nil ? "Avg: \(latest?.heartRate?.avg ?? 0) уд/мин" : "",
color: Color(hex: "ff4757"),
gradientColors: [Color(hex: "ff4757"), Color(hex: "ff6b81")]
)
.frame(maxHeight: .infinity)
}
if let hrv = latest?.hrv {
MetricCardView(
icon: "waveform.path.ecg",
title: "HRV",
value: "\(Int(hrv.avg ?? 0)) мс",
subtitle: hrv.latest != nil ? "Последнее: \(Int(hrv.latest!)) мс" : "Вариабельность",
color: Color(hex: "00d4aa"),
gradientColors: [Color(hex: "00d4aa"), Color(hex: "00b894")]
)
.frame(maxHeight: .infinity)
}
if let steps = latest?.steps {
StepsCard(steps: steps.total ?? 0)
.frame(maxHeight: .infinity)
SleepDetailView(sleep: sleep).presentationDetents([.large]).presentationBackground(Color(hex: "06060f"))
}
}
.padding(.horizontal)
.task {
if healthKit.isAvailable { try? await healthKit.requestAuthorization() }
await loadData()
}
}
// MARK: - Load Data
// 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) ?? []
readiness = try? await r; latest = try? await l; heatmapData = (try? await h) ?? []
isLoading = false
}
// MARK: - Sync HealthKit
func syncHealthKit() async {
guard healthKit.isAvailable else {
showToastMessage("HealthKit недоступен на этом устройстве", success: false)
return
}
guard healthKit.isAvailable else { showToastMsg("HealthKit недоступен", success: false); return }
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
do {
try await healthKit.syncToServer(apiKey: authManager.healthApiKey)
UINotificationFeedbackGenerator().notificationOccurred(.success)
showToastMessage("Данные синхронизированы ✓", success: true)
showToastMsg("Синхронизировано", success: true)
await loadData()
} catch {
UINotificationFeedbackGenerator().notificationOccurred(.error)
showToastMessage(error.localizedDescription, success: false)
showToastMsg(error.localizedDescription, success: false)
}
}
private func showToastMessage(_ message: String, success: Bool) {
toastMessage = message
toastSuccess = success
withAnimation {
showToast = true
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)
}
}