- DashboardView: полный редизайн с приветствием по времени суток, pull-to-refresh - ReadinessCardView: анимированное кольцо, цветные факторы с иконками - MetricCardView: glassmorphism карточки, градиентные иконки, SleepCard/StepsCard - WeeklyChartView: bar chart (Sleep/HRV/Steps) без внешних библиотек - ToastView: уведомления об успехе/ошибке с автоскрытием - HealthKitService: полный сбор метрик + отправка на сервер - HealthModels: HeatmapEntry для недельных данных - Тёмная тема #0a0a1a, haptic feedback, анимации появления
370 lines
12 KiB
Swift
370 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Gradient Icon
|
|
|
|
struct GradientIcon: View {
|
|
let icon: String
|
|
let colors: [Color]
|
|
var size: CGFloat = 36
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Circle()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: colors.map { $0.opacity(0.2) },
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.frame(width: size, height: size)
|
|
|
|
Image(systemName: icon)
|
|
.font(.system(size: size * 0.4))
|
|
.foregroundStyle(
|
|
LinearGradient(
|
|
colors: colors,
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Base Metric Card
|
|
|
|
struct MetricCardView: View {
|
|
let icon: String
|
|
let title: String
|
|
let value: String
|
|
let subtitle: String
|
|
let color: Color
|
|
var gradientColors: [Color]? = nil
|
|
var progress: Double? = nil
|
|
var progressMax: Double = 1.0
|
|
|
|
@State private var appeared = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
GradientIcon(icon: icon, colors: gradientColors ?? [color, color.opacity(0.6)])
|
|
Spacer()
|
|
}
|
|
|
|
Text(value)
|
|
.font(.title2.bold())
|
|
.foregroundColor(.white)
|
|
|
|
Text(title)
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundColor(.white.opacity(0.7))
|
|
|
|
if subtitle.isEmpty == false {
|
|
Text(subtitle)
|
|
.font(.caption)
|
|
.foregroundColor(Color(hex: "8888aa"))
|
|
.lineLimit(2)
|
|
}
|
|
|
|
if let progress = progress {
|
|
VStack(spacing: 4) {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color.white.opacity(0.08))
|
|
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: gradientColors ?? [color, color.opacity(0.6)],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
)
|
|
.frame(width: geo.size.width * min(CGFloat(progress / progressMax), 1.0))
|
|
}
|
|
}
|
|
.frame(height: 6)
|
|
|
|
HStack {
|
|
Spacer()
|
|
Text("\(Int(progress / progressMax * 100))% от цели")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(Color(hex: "8888aa"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(.ultraThinMaterial)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(Color(hex: "12122a").opacity(0.7))
|
|
)
|
|
)
|
|
.shadow(color: .black.opacity(0.15), radius: 8, y: 4)
|
|
.opacity(appeared ? 1 : 0)
|
|
.offset(y: appeared ? 0 : 15)
|
|
.onAppear {
|
|
withAnimation(.easeOut(duration: 0.5).delay(0.1)) {
|
|
appeared = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sleep Card
|
|
|
|
struct SleepCard: View {
|
|
let sleep: SleepData
|
|
@State private var appeared = false
|
|
|
|
var totalHours: Double { sleep.totalSleep ?? 0 }
|
|
var deepMin: Int { Int((sleep.deep ?? 0) * 60) }
|
|
var remHours: String { formatHours(sleep.rem ?? 0) }
|
|
var coreHours: String { formatHours(sleep.core ?? 0) }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
GradientIcon(icon: "moon.fill", colors: [Color(hex: "7c3aed"), Color(hex: "a78bfa")])
|
|
Spacer()
|
|
}
|
|
|
|
Text(String(format: "%.1f ч", totalHours))
|
|
.font(.title2.bold())
|
|
.foregroundColor(.white)
|
|
|
|
Text("Сон")
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundColor(.white.opacity(0.7))
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 12) {
|
|
SleepPhase(label: "Deep", value: "\(deepMin)мин", color: Color(hex: "7c3aed"))
|
|
SleepPhase(label: "REM", value: remHours, color: Color(hex: "a78bfa"))
|
|
SleepPhase(label: "Core", value: coreHours, color: Color(hex: "c4b5fd"))
|
|
}
|
|
.font(.system(size: 10))
|
|
}
|
|
|
|
// Progress to 9h goal
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color.white.opacity(0.08))
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [Color(hex: "7c3aed"), Color(hex: "a78bfa")],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
)
|
|
.frame(width: geo.size.width * min(CGFloat(totalHours / 9.0), 1.0))
|
|
}
|
|
}
|
|
.frame(height: 6)
|
|
}
|
|
.padding(16)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(.ultraThinMaterial)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(Color(hex: "12122a").opacity(0.7))
|
|
)
|
|
)
|
|
.shadow(color: .black.opacity(0.15), radius: 8, y: 4)
|
|
.opacity(appeared ? 1 : 0)
|
|
.offset(y: appeared ? 0 : 15)
|
|
.onAppear {
|
|
withAnimation(.easeOut(duration: 0.5).delay(0.15)) {
|
|
appeared = true
|
|
}
|
|
}
|
|
}
|
|
|
|
private func formatHours(_ h: Double) -> String {
|
|
if h < 1 { return "\(Int(h * 60))мин" }
|
|
return String(format: "%.0fч", h)
|
|
}
|
|
}
|
|
|
|
struct SleepPhase: View {
|
|
let label: String
|
|
let value: String
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(label)
|
|
.foregroundColor(Color(hex: "8888aa"))
|
|
Text(value)
|
|
.foregroundColor(color)
|
|
.fontWeight(.medium)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Steps Card
|
|
|
|
struct StepsCard: View {
|
|
let steps: Int
|
|
let goal: Int = 8000
|
|
@State private var appeared = false
|
|
|
|
var progress: Double { Double(steps) / Double(goal) }
|
|
var percent: Int { Int(progress * 100) }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
GradientIcon(icon: "figure.walk", colors: [Color(hex: "ffa502"), Color(hex: "ff6348")])
|
|
Spacer()
|
|
}
|
|
|
|
Text(formatSteps(steps))
|
|
.font(.title2.bold())
|
|
.foregroundColor(.white)
|
|
|
|
Text("Шаги")
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundColor(.white.opacity(0.7))
|
|
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color.white.opacity(0.08))
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [Color(hex: "ffa502"), Color(hex: "ff6348")],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
)
|
|
.frame(width: geo.size.width * min(CGFloat(progress), 1.0))
|
|
}
|
|
}
|
|
.frame(height: 6)
|
|
|
|
Text("\(percent)% от цели")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(Color(hex: "8888aa"))
|
|
}
|
|
.padding(16)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(.ultraThinMaterial)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(Color(hex: "12122a").opacity(0.7))
|
|
)
|
|
)
|
|
.shadow(color: .black.opacity(0.15), radius: 8, y: 4)
|
|
.opacity(appeared ? 1 : 0)
|
|
.offset(y: appeared ? 0 : 15)
|
|
.onAppear {
|
|
withAnimation(.easeOut(duration: 0.5).delay(0.25)) {
|
|
appeared = true
|
|
}
|
|
}
|
|
}
|
|
|
|
private func formatSteps(_ n: Int) -> String {
|
|
let formatter = NumberFormatter()
|
|
formatter.numberStyle = .decimal
|
|
formatter.groupingSeparator = " "
|
|
return formatter.string(from: NSNumber(value: n)) ?? "\(n)"
|
|
}
|
|
}
|
|
|
|
// MARK: - Insights Card
|
|
|
|
struct InsightsCard: View {
|
|
let readiness: ReadinessResponse?
|
|
let latest: LatestHealthResponse?
|
|
@State private var appeared = false
|
|
|
|
var insights: [(icon: String, text: String, color: Color)] {
|
|
var result: [(String, String, Color)] = []
|
|
|
|
if let r = readiness {
|
|
if r.score >= 80 {
|
|
result.append(("bolt.fill", "Отличный день для тренировки!", Color(hex: "00d4aa")))
|
|
} else if r.score < 60 {
|
|
result.append(("bed.double.fill", "Сегодня лучше отдохнуть", Color(hex: "ff4757")))
|
|
}
|
|
}
|
|
|
|
if let sleep = latest?.sleep?.totalSleep, sleep < 7 {
|
|
result.append(("moon.zzz.fill", "Мало сна — постарайся лечь раньше", Color(hex: "7c3aed")))
|
|
}
|
|
|
|
if let hrv = latest?.hrv?.avg, hrv > 50 {
|
|
result.append(("heart.fill", "HRV в норме — хороший знак", Color(hex: "00d4aa")))
|
|
} else if let hrv = latest?.hrv?.avg, hrv > 0 {
|
|
result.append(("exclamationmark.triangle.fill", "HRV ниже нормы — следи за стрессом", Color(hex: "ffa502")))
|
|
}
|
|
|
|
if let steps = latest?.steps?.total, steps > 0 && steps < 5000 {
|
|
result.append(("figure.walk", "Мало шагов — прогуляйся!", Color(hex: "ffa502")))
|
|
}
|
|
|
|
if result.isEmpty {
|
|
result.append(("sparkles", "Данные обновятся после синхронизации", Color(hex: "8888aa")))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack {
|
|
GradientIcon(icon: "lightbulb.fill", colors: [Color(hex: "ffa502"), Color(hex: "ff6348")])
|
|
Text("Инсайты")
|
|
.font(.headline.weight(.semibold))
|
|
.foregroundColor(.white)
|
|
Spacer()
|
|
}
|
|
|
|
ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in
|
|
HStack(spacing: 12) {
|
|
Image(systemName: insight.icon)
|
|
.font(.subheadline)
|
|
.foregroundColor(insight.color)
|
|
.frame(width: 24)
|
|
|
|
Text(insight.text)
|
|
.font(.subheadline)
|
|
.foregroundColor(.white.opacity(0.85))
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
}
|
|
.padding(20)
|
|
.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)
|
|
.opacity(appeared ? 1 : 0)
|
|
.offset(y: appeared ? 0 : 20)
|
|
.onAppear {
|
|
withAnimation(.easeOut(duration: 0.5).delay(0.3)) {
|
|
appeared = true
|
|
}
|
|
}
|
|
}
|
|
}
|