- DashboardView: полный редизайн с приветствием по времени суток, pull-to-refresh - ReadinessCardView: анимированное кольцо, цветные факторы с иконками - MetricCardView: glassmorphism карточки, градиентные иконки, SleepCard/StepsCard - WeeklyChartView: bar chart (Sleep/HRV/Steps) без внешних библиотек - ToastView: уведомления об успехе/ошибке с автоскрытием - HealthKitService: полный сбор метрик + отправка на сервер - HealthModels: HeatmapEntry для недельных данных - Тёмная тема #0a0a1a, haptic feedback, анимации появления
153 lines
5.3 KiB
Swift
153 lines
5.3 KiB
Swift
import SwiftUI
|
|
|
|
struct ReadinessCardView: View {
|
|
let readiness: ReadinessResponse
|
|
@State private var animatedScore: CGFloat = 0
|
|
@State private var appeared = false
|
|
|
|
var statusColor: Color {
|
|
if readiness.score >= 80 { return Color(hex: "00d4aa") }
|
|
if readiness.score >= 60 { return Color(hex: "ffa502") }
|
|
return Color(hex: "ff4757")
|
|
}
|
|
|
|
var statusText: String {
|
|
if readiness.score >= 80 { return "Отличная готовность 💪" }
|
|
if readiness.score >= 60 { return "Умеренная активность 🚶" }
|
|
return "День отдыха 😴"
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 20) {
|
|
// Score Ring
|
|
ZStack {
|
|
// Background ring
|
|
Circle()
|
|
.stroke(Color.white.opacity(0.08), lineWidth: 14)
|
|
.frame(width: 150, height: 150)
|
|
|
|
// Animated ring
|
|
Circle()
|
|
.trim(from: 0, to: animatedScore / 100)
|
|
.stroke(
|
|
AngularGradient(
|
|
colors: [statusColor.opacity(0.5), statusColor, statusColor.opacity(0.8)],
|
|
center: .center,
|
|
startAngle: .degrees(0),
|
|
endAngle: .degrees(360)
|
|
),
|
|
style: StrokeStyle(lineWidth: 14, lineCap: .round)
|
|
)
|
|
.frame(width: 150, height: 150)
|
|
.rotationEffect(.degrees(-90))
|
|
|
|
// Score text
|
|
VStack(spacing: 2) {
|
|
Text("\(readiness.score)")
|
|
.font(.system(size: 48, weight: .bold, design: .rounded))
|
|
.foregroundColor(statusColor)
|
|
Text("из 100")
|
|
.font(.caption2)
|
|
.foregroundColor(Color(hex: "8888aa"))
|
|
}
|
|
}
|
|
|
|
// Status
|
|
VStack(spacing: 6) {
|
|
Text(statusText)
|
|
.font(.title3.weight(.semibold))
|
|
.foregroundColor(.white)
|
|
|
|
Text(readiness.recommendation)
|
|
.font(.subheadline)
|
|
.foregroundColor(Color(hex: "8888aa"))
|
|
.multilineTextAlignment(.center)
|
|
.lineLimit(3)
|
|
.padding(.horizontal, 8)
|
|
}
|
|
|
|
// Factor bars
|
|
if let f = readiness.factors {
|
|
VStack(spacing: 10) {
|
|
Divider().background(Color.white.opacity(0.1))
|
|
|
|
FactorRow(name: "Сон", icon: "moon.fill", score: f.sleep.score, value: f.sleep.value, color: Color(hex: "7c3aed"))
|
|
FactorRow(name: "HRV", icon: "waveform.path.ecg", score: f.hrv.score, value: f.hrv.value, color: Color(hex: "00d4aa"))
|
|
FactorRow(name: "Пульс", icon: "heart.fill", score: f.rhr.score, value: f.rhr.value, color: Color(hex: "ff4757"))
|
|
FactorRow(name: "Активность", icon: "flame.fill", score: f.activity.score, value: f.activity.value, color: Color(hex: "ffa502"))
|
|
}
|
|
}
|
|
}
|
|
.padding(24)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(.ultraThinMaterial)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(Color(hex: "12122a").opacity(0.7))
|
|
)
|
|
)
|
|
.shadow(color: .black.opacity(0.2), radius: 10, y: 5)
|
|
.padding(.horizontal)
|
|
.onAppear {
|
|
withAnimation(.easeOut(duration: 1.2)) {
|
|
animatedScore = CGFloat(readiness.score)
|
|
}
|
|
}
|
|
.opacity(appeared ? 1 : 0)
|
|
.offset(y: appeared ? 0 : 20)
|
|
.onAppear {
|
|
withAnimation(.easeOut(duration: 0.5).delay(0.1)) {
|
|
appeared = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Factor Row
|
|
|
|
struct FactorRow: View {
|
|
let name: String
|
|
let icon: String
|
|
let score: Int
|
|
let value: String
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: icon)
|
|
.font(.caption)
|
|
.foregroundColor(color)
|
|
.frame(width: 20)
|
|
|
|
Text(name)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundColor(Color(hex: "8888aa"))
|
|
.frame(width: 75, alignment: .leading)
|
|
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(Color.white.opacity(0.08))
|
|
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [color.opacity(0.7), color],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
)
|
|
.frame(width: geo.size.width * CGFloat(score) / 100)
|
|
}
|
|
}
|
|
.frame(height: 6)
|
|
|
|
Text(value)
|
|
.font(.caption)
|
|
.foregroundColor(.white.opacity(0.7))
|
|
.frame(width: 55, alignment: .trailing)
|
|
}
|
|
}
|
|
}
|