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:
@@ -1,19 +1,85 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct ReadinessResponse: Codable {
|
struct ReadinessResponse: Codable {
|
||||||
let score: Int; let status: String; let recommendation: String
|
let score: Int
|
||||||
let date: String?; let factors: ReadinessFactors?
|
let status: String
|
||||||
|
let recommendation: String
|
||||||
|
let date: String?
|
||||||
|
let factors: ReadinessFactors?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ReadinessFactors: Codable {
|
struct ReadinessFactors: Codable {
|
||||||
let sleep: FactorScore; let hrv: FactorScore; let rhr: FactorScore; let activity: FactorScore
|
let sleep: FactorScore
|
||||||
|
let hrv: FactorScore
|
||||||
|
let rhr: FactorScore
|
||||||
|
let activity: FactorScore
|
||||||
}
|
}
|
||||||
struct FactorScore: Codable { let score: Int; let value: String; let baseline: String? }
|
|
||||||
|
struct FactorScore: Codable {
|
||||||
|
let score: Int
|
||||||
|
let value: String
|
||||||
|
let baseline: String?
|
||||||
|
}
|
||||||
|
|
||||||
struct LatestHealthResponse: Codable {
|
struct LatestHealthResponse: Codable {
|
||||||
let sleep: SleepData?; let heartRate: HeartRateData?; let restingHeartRate: RestingHRData?
|
let sleep: SleepData?
|
||||||
let hrv: HRVData?; let steps: StepsData?; let activeEnergy: EnergyData?
|
let heartRate: HeartRateData?
|
||||||
|
let restingHeartRate: RestingHRData?
|
||||||
|
let hrv: HRVData?
|
||||||
|
let steps: StepsData?
|
||||||
|
let activeEnergy: EnergyData?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SleepData: Codable {
|
||||||
|
let totalSleep: Double?
|
||||||
|
let deep: Double?
|
||||||
|
let rem: Double?
|
||||||
|
let core: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HeartRateData: Codable {
|
||||||
|
let avg: Int?
|
||||||
|
let min: Int?
|
||||||
|
let max: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RestingHRData: Codable {
|
||||||
|
let value: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HRVData: Codable {
|
||||||
|
let avg: Double?
|
||||||
|
let latest: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StepsData: Codable {
|
||||||
|
let total: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EnergyData: Codable {
|
||||||
|
let total: Int?
|
||||||
|
let units: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Heatmap / Weekly Data
|
||||||
|
|
||||||
|
struct HeatmapEntry: Codable, Identifiable {
|
||||||
|
let date: String
|
||||||
|
let score: Int?
|
||||||
|
let steps: Int?
|
||||||
|
let sleep: Double?
|
||||||
|
let hrv: Double?
|
||||||
|
let rhr: Double?
|
||||||
|
|
||||||
|
var id: String { date }
|
||||||
|
|
||||||
|
var displayDate: String {
|
||||||
|
let parts = date.split(separator: "-")
|
||||||
|
guard parts.count == 3 else { return date }
|
||||||
|
return "\(parts[2]).\(parts[1])"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HeatmapResponse: Codable {
|
||||||
|
let data: [HeatmapEntry]
|
||||||
}
|
}
|
||||||
struct SleepData: Codable { let totalSleep: Double?; let deep: Double?; let rem: Double?; let core: Double? }
|
|
||||||
struct HeartRateData: Codable { let avg: Int?; let min: Int?; let max: Int? }
|
|
||||||
struct RestingHRData: Codable { let value: Double? }
|
|
||||||
struct HRVData: Codable { let avg: Double?; let latest: Double? }
|
|
||||||
struct StepsData: Codable { let total: Int? }
|
|
||||||
struct EnergyData: Codable { let total: Int?; let units: String? }
|
|
||||||
|
|||||||
@@ -1,32 +1,212 @@
|
|||||||
import HealthKit
|
import HealthKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
class HealthKitService: ObservableObject {
|
class HealthKitService: ObservableObject {
|
||||||
let healthStore = HKHealthStore()
|
let healthStore = HKHealthStore()
|
||||||
|
@Published var isSyncing = false
|
||||||
|
|
||||||
var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
|
var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
|
||||||
|
|
||||||
|
private let typesToRead: Set<HKObjectType> = [
|
||||||
|
HKQuantityType(.heartRate),
|
||||||
|
HKQuantityType(.restingHeartRate),
|
||||||
|
HKQuantityType(.heartRateVariabilitySDNN),
|
||||||
|
HKQuantityType(.stepCount),
|
||||||
|
HKQuantityType(.activeEnergyBurned),
|
||||||
|
HKQuantityType(.oxygenSaturation),
|
||||||
|
HKQuantityType(.distanceWalkingRunning),
|
||||||
|
HKCategoryType(.sleepAnalysis),
|
||||||
|
]
|
||||||
|
|
||||||
func requestAuthorization() async throws {
|
func requestAuthorization() async throws {
|
||||||
let typesToRead: Set<HKObjectType> = [
|
|
||||||
HKQuantityType(.heartRate),
|
|
||||||
HKQuantityType(.restingHeartRate),
|
|
||||||
HKQuantityType(.heartRateVariabilitySDNN),
|
|
||||||
HKQuantityType(.stepCount),
|
|
||||||
HKQuantityType(.activeEnergyBurned),
|
|
||||||
HKQuantityType(.oxygenSaturation),
|
|
||||||
HKCategoryType(.sleepAnalysis),
|
|
||||||
]
|
|
||||||
try await healthStore.requestAuthorization(toShare: [], read: typesToRead)
|
try await healthStore.requestAuthorization(toShare: [], read: typesToRead)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchTodaySteps() async -> Int {
|
// MARK: - Collect All Metrics
|
||||||
guard let type = HKQuantityType.quantityType(forIdentifier: .stepCount) else { return 0 }
|
|
||||||
|
func collectAllMetrics() async -> [[String: Any]] {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let startOfDay = Calendar.current.startOfDay(for: now)
|
let startOfDay = Calendar.current.startOfDay(for: now)
|
||||||
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now)
|
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now)
|
||||||
return await withCheckedContinuation { cont in
|
|
||||||
let q = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, _ in
|
let dateFormatter = DateFormatter()
|
||||||
cont.resume(returning: Int(result?.sumQuantity()?.doubleValue(for: .count()) ?? 0))
|
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z"
|
||||||
|
|
||||||
|
let dateStr = dateFormatter.string(from: startOfDay)
|
||||||
|
|
||||||
|
var metrics: [[String: Any]] = []
|
||||||
|
|
||||||
|
// Step Count
|
||||||
|
let steps = await fetchCumulativeSum(.stepCount, unit: .count(), predicate: predicate)
|
||||||
|
if steps > 0 {
|
||||||
|
metrics.append([
|
||||||
|
"name": "step_count",
|
||||||
|
"units": "count",
|
||||||
|
"data": [["date": dateStr, "qty": steps]]
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resting Heart Rate
|
||||||
|
let rhr = await fetchLatestQuantity(.restingHeartRate, unit: HKUnit.count().unitDivided(by: .minute()), predicate: predicate)
|
||||||
|
if rhr > 0 {
|
||||||
|
metrics.append([
|
||||||
|
"name": "resting_heart_rate",
|
||||||
|
"units": "bpm",
|
||||||
|
"data": [["date": dateStr, "qty": rhr]]
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// HRV
|
||||||
|
let hrvValues = await fetchAllSamples(.heartRateVariabilitySDNN, unit: .secondUnit(with: .milli), predicate: predicate)
|
||||||
|
if !hrvValues.isEmpty {
|
||||||
|
let hrvData = hrvValues.map { sample -> [String: Any] in
|
||||||
|
["date": dateFormatter.string(from: sample.startDate), "qty": sample.quantity.doubleValue(for: .secondUnit(with: .milli))]
|
||||||
}
|
}
|
||||||
healthStore.execute(q)
|
metrics.append([
|
||||||
|
"name": "heart_rate_variability",
|
||||||
|
"units": "ms",
|
||||||
|
"data": hrvData
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heart Rate
|
||||||
|
let hrValues = await fetchAllSamples(.heartRate, unit: HKUnit.count().unitDivided(by: .minute()), predicate: predicate)
|
||||||
|
if !hrValues.isEmpty {
|
||||||
|
let hrData = hrValues.map { sample -> [String: Any] in
|
||||||
|
["date": dateFormatter.string(from: sample.startDate), "qty": sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute()))]
|
||||||
|
}
|
||||||
|
metrics.append([
|
||||||
|
"name": "heart_rate",
|
||||||
|
"units": "bpm",
|
||||||
|
"data": hrData
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active Energy (convert kcal to kJ)
|
||||||
|
let energy = await fetchCumulativeSum(.activeEnergyBurned, unit: .kilocalorie(), predicate: predicate)
|
||||||
|
if energy > 0 {
|
||||||
|
let kJ = energy * 4.184
|
||||||
|
metrics.append([
|
||||||
|
"name": "active_energy",
|
||||||
|
"units": "kJ",
|
||||||
|
"data": [["date": dateStr, "qty": kJ]]
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blood Oxygen
|
||||||
|
let spo2Values = await fetchAllSamples(.oxygenSaturation, unit: .percent(), predicate: predicate)
|
||||||
|
if !spo2Values.isEmpty {
|
||||||
|
let spo2Data = spo2Values.map { sample -> [String: Any] in
|
||||||
|
["date": dateFormatter.string(from: sample.startDate), "qty": sample.quantity.doubleValue(for: .percent()) * 100]
|
||||||
|
}
|
||||||
|
metrics.append([
|
||||||
|
"name": "blood_oxygen_saturation",
|
||||||
|
"units": "%",
|
||||||
|
"data": spo2Data
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walking + Running Distance
|
||||||
|
let distance = await fetchCumulativeSum(.distanceWalkingRunning, unit: .meter(), predicate: predicate)
|
||||||
|
if distance > 0 {
|
||||||
|
metrics.append([
|
||||||
|
"name": "walking_running_distance",
|
||||||
|
"units": "m",
|
||||||
|
"data": [["date": dateStr, "qty": distance]]
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Send to Server
|
||||||
|
|
||||||
|
func syncToServer(apiKey: String) async throws {
|
||||||
|
await MainActor.run { isSyncing = true }
|
||||||
|
defer { Task { @MainActor in isSyncing = false } }
|
||||||
|
|
||||||
|
try await requestAuthorization()
|
||||||
|
|
||||||
|
let metrics = await collectAllMetrics()
|
||||||
|
guard !metrics.isEmpty else {
|
||||||
|
throw HealthKitError.noData
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"data": [
|
||||||
|
"metrics": metrics
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
let jsonData = try JSONSerialization.data(withJSONObject: payload)
|
||||||
|
|
||||||
|
let urlStr = "\(APIService.shared.baseURL)/api/health?key=\(apiKey)"
|
||||||
|
guard let url = URL(string: urlStr) else {
|
||||||
|
throw HealthKitError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = jsonData
|
||||||
|
request.timeoutInterval = 30
|
||||||
|
|
||||||
|
let (_, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
|
(200...299).contains(httpResponse.statusCode) else {
|
||||||
|
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
throw HealthKitError.serverError(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func fetchCumulativeSum(_ identifier: HKQuantityTypeIdentifier, unit: HKUnit, predicate: NSPredicate) async -> Double {
|
||||||
|
guard let type = HKQuantityType.quantityType(forIdentifier: identifier) else { return 0 }
|
||||||
|
return await withCheckedContinuation { cont in
|
||||||
|
let query = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, _ in
|
||||||
|
cont.resume(returning: result?.sumQuantity()?.doubleValue(for: unit) ?? 0)
|
||||||
|
}
|
||||||
|
healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchLatestQuantity(_ identifier: HKQuantityTypeIdentifier, unit: HKUnit, predicate: NSPredicate) async -> Double {
|
||||||
|
guard let type = HKQuantityType.quantityType(forIdentifier: identifier) else { return 0 }
|
||||||
|
return await withCheckedContinuation { cont in
|
||||||
|
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
|
||||||
|
let query = HKSampleQuery(sampleType: type, predicate: predicate, limit: 1, sortDescriptors: [sort]) { _, samples, _ in
|
||||||
|
let value = (samples?.first as? HKQuantitySample)?.quantity.doubleValue(for: unit) ?? 0
|
||||||
|
cont.resume(returning: value)
|
||||||
|
}
|
||||||
|
healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchAllSamples(_ identifier: HKQuantityTypeIdentifier, unit: HKUnit, predicate: NSPredicate) async -> [HKQuantitySample] {
|
||||||
|
guard let type = HKQuantityType.quantityType(forIdentifier: identifier) else { return [] }
|
||||||
|
return await withCheckedContinuation { cont in
|
||||||
|
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
||||||
|
let query = HKSampleQuery(sampleType: type, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { _, samples, _ in
|
||||||
|
cont.resume(returning: (samples as? [HKQuantitySample]) ?? [])
|
||||||
|
}
|
||||||
|
healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum HealthKitError: Error, LocalizedError {
|
||||||
|
case noData
|
||||||
|
case invalidURL
|
||||||
|
case serverError(Int)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .noData: return "Нет данных HealthKit за сегодня"
|
||||||
|
case .invalidURL: return "Неверный URL сервера"
|
||||||
|
case .serverError(let code): return "Ошибка сервера: \(code)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,67 +3,240 @@ import SwiftUI
|
|||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
@EnvironmentObject var authManager: AuthManager
|
@EnvironmentObject var authManager: AuthManager
|
||||||
@StateObject private var healthKit = HealthKitService()
|
@StateObject private var healthKit = HealthKitService()
|
||||||
|
|
||||||
@State private var readiness: ReadinessResponse?
|
@State private var readiness: ReadinessResponse?
|
||||||
@State private var latest: LatestHealthResponse?
|
@State private var latest: LatestHealthResponse?
|
||||||
|
@State private var heatmapData: [HeatmapEntry] = []
|
||||||
@State private var isLoading = true
|
@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 {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
LinearGradient(colors: [Color(hex: "1a1a2e"), Color(hex: "16213e")], startPoint: .top, endPoint: .bottom)
|
// Background
|
||||||
|
Color(hex: "0a0a1a")
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
ScrollView {
|
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
HStack {
|
// MARK: - Header
|
||||||
Text("Привет, \(authManager.userName) 👋").font(.title2.bold()).foregroundColor(.white)
|
headerView
|
||||||
Spacer()
|
.padding(.top, 8)
|
||||||
Button(action: { authManager.logout() }) {
|
|
||||||
Image(systemName: "rectangle.portrait.and.arrow.right").foregroundColor(.white.opacity(0.5))
|
|
||||||
}
|
|
||||||
}.padding(.horizontal).padding(.top)
|
|
||||||
|
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 60)
|
loadingView
|
||||||
} else {
|
} else {
|
||||||
if let r = readiness { ReadinessCardView(readiness: r) }
|
// MARK: - Readiness
|
||||||
if let l = latest {
|
if let r = readiness {
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
|
ReadinessCardView(readiness: r)
|
||||||
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: - 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() }
|
.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 {
|
func loadData() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
|
||||||
async let r = APIService.shared.getReadiness(token: authManager.token)
|
async let r = APIService.shared.getReadiness(token: authManager.token)
|
||||||
async let l = APIService.shared.getLatest(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
|
readiness = try? await r
|
||||||
latest = try? await l
|
latest = try? await l
|
||||||
|
heatmapData = (try? await h) ?? []
|
||||||
|
|
||||||
isLoading = false
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ struct LoginView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
LinearGradient(colors: [Color(hex: "1a1a2e"), Color(hex: "16213e")],
|
Color(hex: "0a0a1a")
|
||||||
startPoint: .top, endPoint: .bottom)
|
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 32) {
|
VStack(spacing: 32) {
|
||||||
|
|||||||
@@ -1,14 +1,369 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct MetricCardView: View {
|
// MARK: - Gradient Icon
|
||||||
let icon: String; let title: String; let value: String; let subtitle: String; let color: Color
|
|
||||||
|
struct GradientIcon: View {
|
||||||
|
let icon: String
|
||||||
|
let colors: [Color]
|
||||||
|
var size: CGFloat = 36
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
ZStack {
|
||||||
Image(systemName: icon).foregroundColor(color).font(.title2)
|
Circle()
|
||||||
Text(value).font(.title2.bold()).foregroundColor(.white)
|
.fill(
|
||||||
Text(title).font(.subheadline).foregroundColor(.white.opacity(0.7))
|
LinearGradient(
|
||||||
Text(subtitle).font(.caption).foregroundColor(.white.opacity(0.5))
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(16).background(Color.white.opacity(0.05)).cornerRadius(16)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,56 +2,151 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ReadinessCardView: View {
|
struct ReadinessCardView: View {
|
||||||
let readiness: ReadinessResponse
|
let readiness: ReadinessResponse
|
||||||
|
@State private var animatedScore: CGFloat = 0
|
||||||
|
@State private var appeared = false
|
||||||
|
|
||||||
var statusColor: Color {
|
var statusColor: Color {
|
||||||
switch readiness.status {
|
if readiness.score >= 80 { return Color(hex: "00d4aa") }
|
||||||
case "ready": return Color(hex: "00d4aa")
|
if readiness.score >= 60 { return Color(hex: "ffa502") }
|
||||||
case "moderate": return Color(hex: "ffa500")
|
return Color(hex: "ff4757")
|
||||||
default: return Color(hex: "ff6b6b")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var statusEmoji: String {
|
|
||||||
switch readiness.status { case "ready": return "💪"; case "moderate": return "🚶"; default: return "😴" }
|
var statusText: String {
|
||||||
|
if readiness.score >= 80 { return "Отличная готовность 💪" }
|
||||||
|
if readiness.score >= 60 { return "Умеренная активность 🚶" }
|
||||||
|
return "День отдыха 😴"
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 20) {
|
||||||
Text("Готовность").font(.headline).foregroundColor(.white.opacity(0.7))
|
// Score Ring
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle().stroke(Color.white.opacity(0.1), lineWidth: 12).frame(width: 140, height: 140)
|
// Background ring
|
||||||
Circle().trim(from: 0, to: CGFloat(readiness.score) / 100)
|
Circle()
|
||||||
.stroke(statusColor, style: StrokeStyle(lineWidth: 12, lineCap: .round))
|
.stroke(Color.white.opacity(0.08), lineWidth: 14)
|
||||||
.frame(width: 140, height: 140).rotationEffect(.degrees(-90))
|
.frame(width: 150, height: 150)
|
||||||
.animation(.easeInOut(duration: 1), value: readiness.score)
|
|
||||||
VStack(spacing: 4) {
|
// Animated ring
|
||||||
Text("\(readiness.score)").font(.system(size: 44, weight: .bold)).foregroundColor(statusColor)
|
Circle()
|
||||||
Text(statusEmoji).font(.title2)
|
.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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(readiness.recommendation).font(.subheadline).foregroundColor(.white.opacity(0.8)).multilineTextAlignment(.center).padding(.horizontal)
|
|
||||||
|
// 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 {
|
if let f = readiness.factors {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 10) {
|
||||||
FactorRow(name: "Сон", score: f.sleep.score, value: f.sleep.value)
|
Divider().background(Color.white.opacity(0.1))
|
||||||
FactorRow(name: "HRV", score: f.hrv.score, value: f.hrv.value)
|
|
||||||
FactorRow(name: "Пульс", score: f.rhr.score, value: f.rhr.value)
|
FactorRow(name: "Сон", icon: "moon.fill", score: f.sleep.score, value: f.sleep.value, color: Color(hex: "7c3aed"))
|
||||||
FactorRow(name: "Активность", score: f.activity.score, value: f.activity.value)
|
FactorRow(name: "HRV", icon: "waveform.path.ecg", score: f.hrv.score, value: f.hrv.value, color: Color(hex: "00d4aa"))
|
||||||
}.padding(.horizontal)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(24).background(Color.white.opacity(0.05)).cornerRadius(20).padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Factor Row
|
||||||
|
|
||||||
struct FactorRow: View {
|
struct FactorRow: View {
|
||||||
let name: String; let score: Int; let value: String
|
let name: String
|
||||||
|
let icon: String
|
||||||
|
let score: Int
|
||||||
|
let value: String
|
||||||
|
let color: Color
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack(spacing: 10) {
|
||||||
Text(name).font(.caption).foregroundColor(.white.opacity(0.6)).frame(width: 70, alignment: .leading)
|
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
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.1))
|
RoundedRectangle(cornerRadius: 3)
|
||||||
RoundedRectangle(cornerRadius: 4).fill(Color(hex: "00d4aa")).frame(width: geo.size.width * CGFloat(score) / 100)
|
.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.6)).frame(width: 60, alignment: .trailing)
|
.frame(height: 6)
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
.frame(width: 55, alignment: .trailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
PulseHealth/Views/ToastView.swift
Normal file
71
PulseHealth/Views/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/WeeklyChartView.swift
Normal file
186
PulseHealth/Views/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