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
|
||||
|
||||
struct ReadinessResponse: Codable {
|
||||
let score: Int; let status: String; let recommendation: String
|
||||
let date: String?; let factors: ReadinessFactors?
|
||||
let score: Int
|
||||
let status: String
|
||||
let recommendation: String
|
||||
let date: String?
|
||||
let factors: ReadinessFactors?
|
||||
}
|
||||
|
||||
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 {
|
||||
let sleep: SleepData?; let heartRate: HeartRateData?; let restingHeartRate: RestingHRData?
|
||||
let hrv: HRVData?; let steps: StepsData?; let activeEnergy: EnergyData?
|
||||
let sleep: SleepData?
|
||||
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 Foundation
|
||||
|
||||
class HealthKitService: ObservableObject {
|
||||
let healthStore = HKHealthStore()
|
||||
@Published var isSyncing = false
|
||||
|
||||
var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
|
||||
|
||||
func requestAuthorization() async throws {
|
||||
let typesToRead: Set<HKObjectType> = [
|
||||
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 {
|
||||
try await healthStore.requestAuthorization(toShare: [], read: typesToRead)
|
||||
}
|
||||
|
||||
func fetchTodaySteps() async -> Int {
|
||||
guard let type = HKQuantityType.quantityType(forIdentifier: .stepCount) else { return 0 }
|
||||
// MARK: - Collect All Metrics
|
||||
|
||||
func collectAllMetrics() async -> [[String: Any]] {
|
||||
let now = Date()
|
||||
let startOfDay = Calendar.current.startOfDay(for: now)
|
||||
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now)
|
||||
|
||||
let dateFormatter = DateFormatter()
|
||||
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))]
|
||||
}
|
||||
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 q = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, _ in
|
||||
cont.resume(returning: Int(result?.sumQuantity()?.doubleValue(for: .count()) ?? 0))
|
||||
let query = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, _ in
|
||||
cont.resume(returning: result?.sumQuantity()?.doubleValue(for: unit) ?? 0)
|
||||
}
|
||||
healthStore.execute(q)
|
||||
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 {
|
||||
@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"))
|
||||
// MARK: - Readiness
|
||||
if let r = readiness {
|
||||
ReadinessCardView(readiness: r)
|
||||
}
|
||||
if let rhr = l.restingHeartRate {
|
||||
MetricCardView(icon: "heart.fill", title: "Пульс покоя",
|
||||
value: "\(Int(rhr.value ?? 0)) уд/мин", subtitle: "Resting HR",
|
||||
color: Color(hex: "ff6b6b"))
|
||||
|
||||
// MARK: - Metrics Grid
|
||||
metricsGrid
|
||||
|
||||
// MARK: - Weekly Chart
|
||||
if !heatmapData.isEmpty {
|
||||
WeeklyChartCard(heatmapData: heatmapData)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 20)
|
||||
|
||||
// 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("\(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ struct LoginView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LinearGradient(colors: [Color(hex: "1a1a2e"), Color(hex: "16213e")],
|
||||
startPoint: .top, endPoint: .bottom)
|
||||
Color(hex: "0a0a1a")
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 32) {
|
||||
|
||||
@@ -1,14 +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
|
||||
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) {
|
||||
Image(systemName: icon).foregroundColor(color).font(.title2)
|
||||
Text(value).font(.title2.bold()).foregroundColor(.white)
|
||||
Text(title).font(.subheadline).foregroundColor(.white.opacity(0.7))
|
||||
Text(subtitle).font(.caption).foregroundColor(.white.opacity(0.5))
|
||||
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 {
|
||||
let readiness: ReadinessResponse
|
||||
@State private var animatedScore: CGFloat = 0
|
||||
@State private var appeared = false
|
||||
|
||||
var statusColor: Color {
|
||||
switch readiness.status {
|
||||
case "ready": return Color(hex: "00d4aa")
|
||||
case "moderate": return Color(hex: "ffa500")
|
||||
default: return Color(hex: "ff6b6b")
|
||||
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 statusEmoji: String {
|
||||
switch readiness.status { case "ready": return "💪"; case "moderate": return "🚶"; default: return "😴" }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Готовность").font(.headline).foregroundColor(.white.opacity(0.7))
|
||||
VStack(spacing: 20) {
|
||||
// Score Ring
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.1), lineWidth: 12).frame(width: 140, height: 140)
|
||||
Circle().trim(from: 0, to: CGFloat(readiness.score) / 100)
|
||||
.stroke(statusColor, style: StrokeStyle(lineWidth: 12, lineCap: .round))
|
||||
.frame(width: 140, height: 140).rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 1), value: readiness.score)
|
||||
VStack(spacing: 4) {
|
||||
Text("\(readiness.score)").font(.system(size: 44, weight: .bold)).foregroundColor(statusColor)
|
||||
Text(statusEmoji).font(.title2)
|
||||
}
|
||||
}
|
||||
Text(readiness.recommendation).font(.subheadline).foregroundColor(.white.opacity(0.8)).multilineTextAlignment(.center).padding(.horizontal)
|
||||
if let f = readiness.factors {
|
||||
VStack(spacing: 8) {
|
||||
FactorRow(name: "Сон", score: f.sleep.score, value: f.sleep.value)
|
||||
FactorRow(name: "HRV", score: f.hrv.score, value: f.hrv.value)
|
||||
FactorRow(name: "Пульс", score: f.rhr.score, value: f.rhr.value)
|
||||
FactorRow(name: "Активность", score: f.activity.score, value: f.activity.value)
|
||||
}.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(24).background(Color.white.opacity(0.05)).cornerRadius(20).padding(.horizontal)
|
||||
// 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 score: Int; let value: String
|
||||
let name: String
|
||||
let icon: String
|
||||
let score: Int
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(name).font(.caption).foregroundColor(.white.opacity(0.6)).frame(width: 70, alignment: .leading)
|
||||
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: 4).fill(Color.white.opacity(0.1))
|
||||
RoundedRectangle(cornerRadius: 4).fill(Color(hex: "00d4aa")).frame(width: geo.size.width * CGFloat(score) / 100)
|
||||
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.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