- 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 сервиса
227 lines
7.4 KiB
Swift
227 lines
7.4 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|