feat: полноценное Pulse приложение с TabBar
- Auth: переключено на Pulse API (api.digital-home.site) вместо health - TabBar: Главная, Задачи, Привычки, Здоровье, Финансы - Models: TaskModels, HabitModels, FinanceModels, обновлённые AuthModels - Services: APIService (Pulse API), HealthAPIService (health отдельно) - Dashboard: обзор дня с задачами, привычками, readiness, балансом - Tasks: список, фильтр, создание, выполнение, удаление - Habits: список с прогресс-баром, отметка выполнения, стрики - Health: бывший DashboardView, HealthKit sync через health API key - Finance: баланс, список транзакций, добавление расхода/дохода - Health данные через x-api-key вместо JWT токена health сервиса
This commit is contained in:
226
PulseHealth/Views/Health/HealthView.swift
Normal file
226
PulseHealth/Views/Health/HealthView.swift
Normal file
@@ -0,0 +1,226 @@
|
||||
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
|
||||
|
||||
// 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 "Доброй ночи"
|
||||
}
|
||||
}
|
||||
|
||||
var dateString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "ru_RU")
|
||||
formatter.dateFormat = "d MMMM, EEEE"
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(hex: "0a0a1a")
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 20) {
|
||||
// MARK: - Header
|
||||
headerView
|
||||
.padding(.top, 8)
|
||||
|
||||
if isLoading {
|
||||
loadingView
|
||||
} else {
|
||||
// MARK: - Readiness
|
||||
if let r = readiness {
|
||||
ReadinessCardView(readiness: r)
|
||||
}
|
||||
|
||||
// MARK: - Metrics Grid
|
||||
metricsGrid
|
||||
|
||||
// MARK: - Weekly Chart
|
||||
if !heatmapData.isEmpty {
|
||||
WeeklyChartCard(heatmapData: heatmapData)
|
||||
}
|
||||
|
||||
// MARK: - Insights
|
||||
InsightsCard(readiness: readiness, latest: latest)
|
||||
|
||||
Spacer(minLength: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await loadData()
|
||||
}
|
||||
}
|
||||
.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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// MARK: - Load Data
|
||||
|
||||
func loadData() async {
|
||||
isLoading = true
|
||||
|
||||
let apiKey = authManager.healthApiKey
|
||||
|
||||
async let r = HealthAPIService.shared.getReadiness(apiKey: apiKey)
|
||||
async let l = HealthAPIService.shared.getLatest(apiKey: apiKey)
|
||||
async let h = HealthAPIService.shared.getHeatmap(apiKey: apiKey, days: 7)
|
||||
|
||||
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 !authManager.healthApiKey.isEmpty else {
|
||||
showToastMessage("Health API ключ не найден", success: false)
|
||||
return
|
||||
}
|
||||
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
|
||||
do {
|
||||
try await healthKit.syncToServer(apiKey: authManager.healthApiKey)
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
showToastMessage("Данные синхронизированы ✓", success: true)
|
||||
await loadData()
|
||||
} catch {
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
showToastMessage(error.localizedDescription, success: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func showToastMessage(_ message: String, success: Bool) {
|
||||
toastMessage = message
|
||||
toastSuccess = success
|
||||
withAnimation {
|
||||
showToast = true
|
||||
}
|
||||
}
|
||||
}
|
||||
369
PulseHealth/Views/Health/MetricCardView.swift
Normal file
369
PulseHealth/Views/Health/MetricCardView.swift
Normal file
@@ -0,0 +1,369 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
152
PulseHealth/Views/Health/ReadinessCardView.swift
Normal file
152
PulseHealth/Views/Health/ReadinessCardView.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
71
PulseHealth/Views/Health/ToastView.swift
Normal file
71
PulseHealth/Views/Health/ToastView.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ToastView: View {
|
||||
let message: String
|
||||
let isSuccess: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: isSuccess ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundColor(isSuccess ? Color(hex: "00d4aa") : Color(hex: "ff4757"))
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(
|
||||
(isSuccess ? Color(hex: "00d4aa") : Color(hex: "ff4757")).opacity(0.3),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.3), radius: 10, y: 5)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toast Modifier
|
||||
|
||||
struct ToastModifier: ViewModifier {
|
||||
@Binding var isShowing: Bool
|
||||
let message: String
|
||||
let isSuccess: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
content
|
||||
|
||||
if isShowing {
|
||||
ToastView(message: message, isSuccess: isSuccess)
|
||||
.padding(.bottom, 40)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.zIndex(100)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
isShowing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: isShowing)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func toast(isShowing: Binding<Bool>, message: String, isSuccess: Bool) -> some View {
|
||||
modifier(ToastModifier(isShowing: isShowing, message: message, isSuccess: isSuccess))
|
||||
}
|
||||
}
|
||||
186
PulseHealth/Views/Health/WeeklyChartView.swift
Normal file
186
PulseHealth/Views/Health/WeeklyChartView.swift
Normal file
@@ -0,0 +1,186 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Weekly Chart Card
|
||||
|
||||
struct WeeklyChartCard: View {
|
||||
let heatmapData: [HeatmapEntry]
|
||||
|
||||
enum ChartType: String, CaseIterable {
|
||||
case sleep = "Сон"
|
||||
case hrv = "HRV"
|
||||
case steps = "Шаги"
|
||||
}
|
||||
|
||||
@State private var selectedChart: ChartType = .sleep
|
||||
@State private var appeared = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header
|
||||
HStack {
|
||||
GradientIcon(icon: "chart.bar.fill", colors: [Color(hex: "7c3aed"), Color(hex: "00d4aa")])
|
||||
|
||||
Text("За неделю")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Segmented picker
|
||||
HStack(spacing: 4) {
|
||||
ForEach(ChartType.allCases, id: \.self) { type in
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
selectedChart = type
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
} label: {
|
||||
Text(type.rawValue)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundColor(selectedChart == type ? .white : Color(hex: "8888aa"))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
selectedChart == type
|
||||
? Color(hex: "7c3aed").opacity(0.5)
|
||||
: Color.clear
|
||||
)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.background(Color(hex: "1a1a3e"))
|
||||
.cornerRadius(12)
|
||||
|
||||
// Chart
|
||||
BarChartView(
|
||||
values: chartValues,
|
||||
color: chartColor,
|
||||
maxValue: chartMaxValue,
|
||||
unit: chartUnit,
|
||||
appeared: appeared
|
||||
)
|
||||
.frame(height: 160)
|
||||
}
|
||||
.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)
|
||||
.onAppear {
|
||||
withAnimation(.easeOut(duration: 0.8).delay(0.3)) {
|
||||
appeared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chartValues: [(date: String, value: Double)] {
|
||||
heatmapData.map { entry in
|
||||
let val: Double
|
||||
switch selectedChart {
|
||||
case .sleep: val = entry.sleep ?? 0
|
||||
case .hrv: val = entry.hrv ?? 0
|
||||
case .steps: val = Double(entry.steps ?? 0)
|
||||
}
|
||||
return (date: entry.displayDate, value: val)
|
||||
}
|
||||
}
|
||||
|
||||
private var chartColor: Color {
|
||||
switch selectedChart {
|
||||
case .sleep: return Color(hex: "7c3aed")
|
||||
case .hrv: return Color(hex: "00d4aa")
|
||||
case .steps: return Color(hex: "ffa502")
|
||||
}
|
||||
}
|
||||
|
||||
private var chartMaxValue: Double {
|
||||
switch selectedChart {
|
||||
case .sleep: return 10
|
||||
case .hrv: return 120
|
||||
case .steps: return 12000
|
||||
}
|
||||
}
|
||||
|
||||
private var chartUnit: String {
|
||||
switch selectedChart {
|
||||
case .sleep: return "ч"
|
||||
case .hrv: return "мс"
|
||||
case .steps: return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bar Chart
|
||||
|
||||
struct BarChartView: View {
|
||||
let values: [(date: String, value: Double)]
|
||||
let color: Color
|
||||
let maxValue: Double
|
||||
let unit: String
|
||||
let appeared: Bool
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let barWidth = max((geo.size.width - CGFloat(values.count - 1) * 8) / CGFloat(max(values.count, 1)), 10)
|
||||
let chartHeight = geo.size.height - 30
|
||||
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
ForEach(Array(values.enumerated()), id: \.offset) { index, item in
|
||||
VStack(spacing: 4) {
|
||||
// Value label
|
||||
if item.value > 0 {
|
||||
Text(formatValue(item.value))
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundColor(Color(hex: "8888aa"))
|
||||
}
|
||||
|
||||
// Bar
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [color, color.opacity(0.5)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.frame(
|
||||
width: barWidth,
|
||||
height: appeared
|
||||
? max(CGFloat(item.value / maxValue) * chartHeight, 4)
|
||||
: 4
|
||||
)
|
||||
.animation(
|
||||
.spring(response: 0.6, dampingFraction: 0.7).delay(Double(index) * 0.05),
|
||||
value: appeared
|
||||
)
|
||||
|
||||
// Date label
|
||||
Text(item.date)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(Color(hex: "8888aa"))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatValue(_ value: Double) -> String {
|
||||
if value >= 1000 {
|
||||
return String(format: "%.1fк", value / 1000)
|
||||
} else if value == floor(value) {
|
||||
return "\(Int(value))\(unit)"
|
||||
} else {
|
||||
return String(format: "%.1f\(unit)", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user