feat: Full redesign — glassmorphism, weekly charts, HealthKit sync, toast notifications
- DashboardView: полный редизайн с приветствием по времени суток, pull-to-refresh - ReadinessCardView: анимированное кольцо, цветные факторы с иконками - MetricCardView: glassmorphism карточки, градиентные иконки, SleepCard/StepsCard - WeeklyChartView: bar chart (Sleep/HRV/Steps) без внешних библиотек - ToastView: уведомления об успехе/ошибке с автоскрытием - HealthKitService: полный сбор метрик + отправка на сервер - HealthModels: HeatmapEntry для недельных данных - Тёмная тема #0a0a1a, haptic feedback, анимации появления
This commit is contained in:
@@ -3,67 +3,240 @@ import SwiftUI
|
||||
struct DashboardView: 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 {
|
||||
LinearGradient(colors: [Color(hex: "1a1a2e"), Color(hex: "16213e")], startPoint: .top, endPoint: .bottom)
|
||||
// Background
|
||||
Color(hex: "0a0a1a")
|
||||
.ignoresSafeArea()
|
||||
ScrollView {
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 20) {
|
||||
HStack {
|
||||
Text("Привет, \(authManager.userName) 👋").font(.title2.bold()).foregroundColor(.white)
|
||||
Spacer()
|
||||
Button(action: { authManager.logout() }) {
|
||||
Image(systemName: "rectangle.portrait.and.arrow.right").foregroundColor(.white.opacity(0.5))
|
||||
}
|
||||
}.padding(.horizontal).padding(.top)
|
||||
// MARK: - Header
|
||||
headerView
|
||||
.padding(.top, 8)
|
||||
|
||||
if isLoading {
|
||||
ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 60)
|
||||
loadingView
|
||||
} else {
|
||||
if let r = readiness { ReadinessCardView(readiness: r) }
|
||||
if let l = latest {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
|
||||
if let sleep = l.sleep {
|
||||
MetricCardView(icon: "moon.fill", title: "Сон",
|
||||
value: String(format: "%.1f ч", sleep.totalSleep ?? 0),
|
||||
subtitle: "Глубокий: \(String(format: "%.0f мин", (sleep.deep ?? 0) * 60))",
|
||||
color: Color(hex: "6c63ff"))
|
||||
}
|
||||
if let rhr = l.restingHeartRate {
|
||||
MetricCardView(icon: "heart.fill", title: "Пульс покоя",
|
||||
value: "\(Int(rhr.value ?? 0)) уд/мин", subtitle: "Resting HR",
|
||||
color: Color(hex: "ff6b6b"))
|
||||
}
|
||||
if let hrv = l.hrv {
|
||||
MetricCardView(icon: "waveform.path.ecg", title: "HRV",
|
||||
value: "\(Int(hrv.avg ?? 0)) мс", subtitle: "Вариабельность",
|
||||
color: Color(hex: "00d4aa"))
|
||||
}
|
||||
if let steps = l.steps {
|
||||
MetricCardView(icon: "figure.walk", title: "Шаги",
|
||||
value: "\(steps.total ?? 0)", subtitle: "Сегодня",
|
||||
color: Color(hex: "ffa500"))
|
||||
}
|
||||
}.padding(.horizontal)
|
||||
// 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)
|
||||
}
|
||||
Spacer(minLength: 20)
|
||||
}
|
||||
}
|
||||
.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("\(greeting), \(authManager.userName) 👋")
|
||||
.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)
|
||||
|
||||
// Logout
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
authManager.logout()
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(hex: "1a1a3e"))
|
||||
.frame(width: 42, height: 42)
|
||||
|
||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color(hex: "8888aa"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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) {
|
||||
// Sleep
|
||||
if let sleep = latest?.sleep {
|
||||
SleepCard(sleep: sleep)
|
||||
}
|
||||
|
||||
// Heart Rate
|
||||
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")]
|
||||
)
|
||||
}
|
||||
|
||||
// HRV
|
||||
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")]
|
||||
)
|
||||
}
|
||||
|
||||
// Steps
|
||||
if let steps = latest?.steps {
|
||||
StepsCard(steps: steps.total ?? 0)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// MARK: - Load Data
|
||||
|
||||
func loadData() async {
|
||||
isLoading = true
|
||||
|
||||
async let r = APIService.shared.getReadiness(token: authManager.token)
|
||||
async let l = APIService.shared.getLatest(token: authManager.token)
|
||||
async let h = APIService.shared.getHeatmap(token: authManager.token, 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.apiKey.isEmpty else {
|
||||
showToastMessage("API ключ не найден. Войдите заново.", success: false)
|
||||
return
|
||||
}
|
||||
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
|
||||
do {
|
||||
try await healthKit.syncToServer(apiKey: authManager.apiKey)
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
showToastMessage("Данные синхронизированы ✓", success: true)
|
||||
// Reload dashboard after sync
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user