diff --git a/PulseHealth/Models/HealthModels.swift b/PulseHealth/Models/HealthModels.swift index 3cc3076..88425fc 100644 --- a/PulseHealth/Models/HealthModels.swift +++ b/PulseHealth/Models/HealthModels.swift @@ -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? } diff --git a/PulseHealth/Services/HealthKitService.swift b/PulseHealth/Services/HealthKitService.swift index 1d77e95..1368dbf 100644 --- a/PulseHealth/Services/HealthKitService.swift +++ b/PulseHealth/Services/HealthKitService.swift @@ -1,32 +1,212 @@ import HealthKit +import Foundation class HealthKitService: ObservableObject { let healthStore = HKHealthStore() + @Published var isSyncing = false + var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() } + private let typesToRead: Set = [ + HKQuantityType(.heartRate), + HKQuantityType(.restingHeartRate), + HKQuantityType(.heartRateVariabilitySDNN), + HKQuantityType(.stepCount), + HKQuantityType(.activeEnergyBurned), + HKQuantityType(.oxygenSaturation), + HKQuantityType(.distanceWalkingRunning), + HKCategoryType(.sleepAnalysis), + ] + func requestAuthorization() async throws { - let typesToRead: Set = [ - HKQuantityType(.heartRate), - HKQuantityType(.restingHeartRate), - HKQuantityType(.heartRateVariabilitySDNN), - HKQuantityType(.stepCount), - HKQuantityType(.activeEnergyBurned), - HKQuantityType(.oxygenSaturation), - HKCategoryType(.sleepAnalysis), - ] 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) - 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 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))] } - 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)" } } } diff --git a/PulseHealth/Views/DashboardView.swift b/PulseHealth/Views/DashboardView.swift index ec7ab30..5085e37 100644 --- a/PulseHealth/Views/DashboardView.swift +++ b/PulseHealth/Views/DashboardView.swift @@ -3,67 +3,240 @@ import SwiftUI struct DashboardView: View { @EnvironmentObject var authManager: AuthManager @StateObject private var healthKit = HealthKitService() + @State private var readiness: ReadinessResponse? @State private var latest: LatestHealthResponse? + @State private var heatmapData: [HeatmapEntry] = [] @State private var isLoading = true + // Toast state + @State private var showToast = false + @State private var toastMessage = "" + @State private var toastSuccess = true + + var greeting: String { + let hour = Calendar.current.component(.hour, from: Date()) + switch hour { + case 5..<12: return "Доброе утро" + case 12..<17: return "Добрый день" + case 17..<22: return "Добрый вечер" + default: return "Доброй ночи" + } + } + + var dateString: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ru_RU") + formatter.dateFormat = "d MMMM, EEEE" + return formatter.string(from: Date()) + } + var body: some View { ZStack { - LinearGradient(colors: [Color(hex: "1a1a2e"), Color(hex: "16213e")], startPoint: .top, endPoint: .bottom) + // Background + Color(hex: "0a0a1a") .ignoresSafeArea() - ScrollView { + + ScrollView(showsIndicators: false) { VStack(spacing: 20) { - HStack { - Text("Привет, \(authManager.userName) 👋").font(.title2.bold()).foregroundColor(.white) - Spacer() - Button(action: { authManager.logout() }) { - Image(systemName: "rectangle.portrait.and.arrow.right").foregroundColor(.white.opacity(0.5)) - } - }.padding(.horizontal).padding(.top) + // MARK: - Header + headerView + .padding(.top, 8) if isLoading { - ProgressView().tint(Color(hex: "00d4aa")).padding(.top, 60) + loadingView } else { - if let r = readiness { ReadinessCardView(readiness: r) } - if let l = latest { - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { - if let sleep = l.sleep { - MetricCardView(icon: "moon.fill", title: "Сон", - value: String(format: "%.1f ч", sleep.totalSleep ?? 0), - subtitle: "Глубокий: \(String(format: "%.0f мин", (sleep.deep ?? 0) * 60))", - color: Color(hex: "6c63ff")) - } - if let rhr = l.restingHeartRate { - MetricCardView(icon: "heart.fill", title: "Пульс покоя", - value: "\(Int(rhr.value ?? 0)) уд/мин", subtitle: "Resting HR", - color: Color(hex: "ff6b6b")) - } - if let hrv = l.hrv { - MetricCardView(icon: "waveform.path.ecg", title: "HRV", - value: "\(Int(hrv.avg ?? 0)) мс", subtitle: "Вариабельность", - color: Color(hex: "00d4aa")) - } - if let steps = l.steps { - MetricCardView(icon: "figure.walk", title: "Шаги", - value: "\(steps.total ?? 0)", subtitle: "Сегодня", - color: Color(hex: "ffa500")) - } - }.padding(.horizontal) + // MARK: - Readiness + if let r = readiness { + ReadinessCardView(readiness: r) } + + // MARK: - Metrics Grid + metricsGrid + + // MARK: - Weekly Chart + if !heatmapData.isEmpty { + WeeklyChartCard(heatmapData: heatmapData) + } + + // MARK: - Insights + InsightsCard(readiness: readiness, latest: latest) + + Spacer(minLength: 30) } - Spacer(minLength: 20) } } + .refreshable { + await loadData() + } } + .toast(isShowing: $showToast, message: toastMessage, isSuccess: toastSuccess) .task { await loadData() } } + // MARK: - Header + + private var headerView: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("\(greeting), \(authManager.userName) 👋") + .font(.title2.bold()) + .foregroundColor(.white) + + Text(dateString) + .font(.subheadline) + .foregroundColor(Color(hex: "8888aa")) + } + + Spacer() + + // Sync button + Button { + Task { await syncHealthKit() } + } label: { + ZStack { + Circle() + .fill(Color(hex: "1a1a3e")) + .frame(width: 42, height: 42) + + if healthKit.isSyncing { + ProgressView() + .tint(Color(hex: "00d4aa")) + .scaleEffect(0.8) + } else { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(Color(hex: "00d4aa")) + } + } + } + .disabled(healthKit.isSyncing) + + // Logout + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + authManager.logout() + } label: { + ZStack { + Circle() + .fill(Color(hex: "1a1a3e")) + .frame(width: 42, height: 42) + + Image(systemName: "rectangle.portrait.and.arrow.right") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color(hex: "8888aa")) + } + } + } + } + .padding(.horizontal) + } + + // MARK: - Loading + + private var loadingView: some View { + VStack(spacing: 16) { + ProgressView() + .tint(Color(hex: "00d4aa")) + .scaleEffect(1.2) + + Text("Загрузка данных...") + .font(.subheadline) + .foregroundColor(Color(hex: "8888aa")) + } + .padding(.top, 80) + } + + // MARK: - Metrics Grid + + private var metricsGrid: some View { + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { + // Sleep + if let sleep = latest?.sleep { + SleepCard(sleep: sleep) + } + + // Heart Rate + if let rhr = latest?.restingHeartRate { + MetricCardView( + icon: "heart.fill", + title: "Пульс покоя", + value: "\(Int(rhr.value ?? 0)) уд/мин", + subtitle: latest?.heartRate != nil ? "Avg: \(latest?.heartRate?.avg ?? 0) уд/мин" : "", + color: Color(hex: "ff4757"), + gradientColors: [Color(hex: "ff4757"), Color(hex: "ff6b81")] + ) + } + + // HRV + if let hrv = latest?.hrv { + MetricCardView( + icon: "waveform.path.ecg", + title: "HRV", + value: "\(Int(hrv.avg ?? 0)) мс", + subtitle: hrv.latest != nil ? "Последнее: \(Int(hrv.latest!)) мс" : "Вариабельность", + color: Color(hex: "00d4aa"), + gradientColors: [Color(hex: "00d4aa"), Color(hex: "00b894")] + ) + } + + // Steps + if let steps = latest?.steps { + StepsCard(steps: steps.total ?? 0) + } + } + .padding(.horizontal) + } + + // MARK: - Load Data + func loadData() async { isLoading = true + async let r = APIService.shared.getReadiness(token: authManager.token) async let l = APIService.shared.getLatest(token: authManager.token) + async let h = APIService.shared.getHeatmap(token: authManager.token, days: 7) + readiness = try? await r latest = try? await l + heatmapData = (try? await h) ?? [] + isLoading = false } + + // MARK: - Sync HealthKit + + func syncHealthKit() async { + guard healthKit.isAvailable else { + showToastMessage("HealthKit недоступен на этом устройстве", success: false) + return + } + + guard !authManager.apiKey.isEmpty else { + showToastMessage("API ключ не найден. Войдите заново.", success: false) + return + } + + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + + do { + try await healthKit.syncToServer(apiKey: authManager.apiKey) + UINotificationFeedbackGenerator().notificationOccurred(.success) + showToastMessage("Данные синхронизированы ✓", success: true) + // Reload dashboard after sync + await loadData() + } catch { + UINotificationFeedbackGenerator().notificationOccurred(.error) + showToastMessage(error.localizedDescription, success: false) + } + } + + private func showToastMessage(_ message: String, success: Bool) { + toastMessage = message + toastSuccess = success + withAnimation { + showToast = true + } + } } diff --git a/PulseHealth/Views/LoginView.swift b/PulseHealth/Views/LoginView.swift index f24c866..8262dee 100644 --- a/PulseHealth/Views/LoginView.swift +++ b/PulseHealth/Views/LoginView.swift @@ -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) { diff --git a/PulseHealth/Views/MetricCardView.swift b/PulseHealth/Views/MetricCardView.swift index 909511e..6a80cda 100644 --- a/PulseHealth/Views/MetricCardView.swift +++ b/PulseHealth/Views/MetricCardView.swift @@ -1,14 +1,369 @@ import SwiftUI -struct MetricCardView: View { - let icon: String; let title: String; let value: String; let subtitle: String; let color: Color +// MARK: - Gradient Icon + +struct GradientIcon: View { + let icon: String + let colors: [Color] + var size: CGFloat = 36 + 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)) + ZStack { + Circle() + .fill( + LinearGradient( + colors: colors.map { $0.opacity(0.2) }, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: size, height: size) + + Image(systemName: icon) + .font(.system(size: size * 0.4)) + .foregroundStyle( + LinearGradient( + colors: colors, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + } +} + +// MARK: - Base Metric Card + +struct MetricCardView: View { + let icon: String + let title: String + let value: String + let subtitle: String + let color: Color + var gradientColors: [Color]? = nil + var progress: Double? = nil + var progressMax: Double = 1.0 + + @State private var appeared = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + GradientIcon(icon: icon, colors: gradientColors ?? [color, color.opacity(0.6)]) + Spacer() + } + + Text(value) + .font(.title2.bold()) + .foregroundColor(.white) + + Text(title) + .font(.subheadline.weight(.medium)) + .foregroundColor(.white.opacity(0.7)) + + if subtitle.isEmpty == false { + Text(subtitle) + .font(.caption) + .foregroundColor(Color(hex: "8888aa")) + .lineLimit(2) + } + + if let progress = progress { + VStack(spacing: 4) { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.white.opacity(0.08)) + + RoundedRectangle(cornerRadius: 4) + .fill( + LinearGradient( + colors: gradientColors ?? [color, color.opacity(0.6)], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: geo.size.width * min(CGFloat(progress / progressMax), 1.0)) + } + } + .frame(height: 6) + + HStack { + Spacer() + Text("\(Int(progress / progressMax * 100))% от цели") + .font(.system(size: 10)) + .foregroundColor(Color(hex: "8888aa")) + } + } + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20) + .fill(Color(hex: "12122a").opacity(0.7)) + ) + ) + .shadow(color: .black.opacity(0.15), radius: 8, y: 4) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 15) + .onAppear { + withAnimation(.easeOut(duration: 0.5).delay(0.1)) { + appeared = true + } + } + } +} + +// MARK: - Sleep Card + +struct SleepCard: View { + let sleep: SleepData + @State private var appeared = false + + var totalHours: Double { sleep.totalSleep ?? 0 } + var deepMin: Int { Int((sleep.deep ?? 0) * 60) } + var remHours: String { formatHours(sleep.rem ?? 0) } + var coreHours: String { formatHours(sleep.core ?? 0) } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + GradientIcon(icon: "moon.fill", colors: [Color(hex: "7c3aed"), Color(hex: "a78bfa")]) + Spacer() + } + + Text(String(format: "%.1f ч", totalHours)) + .font(.title2.bold()) + .foregroundColor(.white) + + Text("Сон") + .font(.subheadline.weight(.medium)) + .foregroundColor(.white.opacity(0.7)) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 12) { + SleepPhase(label: "Deep", value: "\(deepMin)мин", color: Color(hex: "7c3aed")) + SleepPhase(label: "REM", value: remHours, color: Color(hex: "a78bfa")) + SleepPhase(label: "Core", value: coreHours, color: Color(hex: "c4b5fd")) + } + .font(.system(size: 10)) + } + + // Progress to 9h goal + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.white.opacity(0.08)) + RoundedRectangle(cornerRadius: 4) + .fill( + LinearGradient( + colors: [Color(hex: "7c3aed"), Color(hex: "a78bfa")], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: geo.size.width * min(CGFloat(totalHours / 9.0), 1.0)) + } + } + .frame(height: 6) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20) + .fill(Color(hex: "12122a").opacity(0.7)) + ) + ) + .shadow(color: .black.opacity(0.15), radius: 8, y: 4) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 15) + .onAppear { + withAnimation(.easeOut(duration: 0.5).delay(0.15)) { + appeared = true + } + } + } + + private func formatHours(_ h: Double) -> String { + if h < 1 { return "\(Int(h * 60))мин" } + return String(format: "%.0fч", h) + } +} + +struct SleepPhase: View { + let label: String + let value: String + let color: Color + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .foregroundColor(Color(hex: "8888aa")) + Text(value) + .foregroundColor(color) + .fontWeight(.medium) + } + } +} + +// MARK: - Steps Card + +struct StepsCard: View { + let steps: Int + let goal: Int = 8000 + @State private var appeared = false + + var progress: Double { Double(steps) / Double(goal) } + var percent: Int { Int(progress * 100) } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + GradientIcon(icon: "figure.walk", colors: [Color(hex: "ffa502"), Color(hex: "ff6348")]) + Spacer() + } + + Text(formatSteps(steps)) + .font(.title2.bold()) + .foregroundColor(.white) + + Text("Шаги") + .font(.subheadline.weight(.medium)) + .foregroundColor(.white.opacity(0.7)) + + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.white.opacity(0.08)) + RoundedRectangle(cornerRadius: 4) + .fill( + LinearGradient( + colors: [Color(hex: "ffa502"), Color(hex: "ff6348")], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: geo.size.width * min(CGFloat(progress), 1.0)) + } + } + .frame(height: 6) + + Text("\(percent)% от цели") + .font(.system(size: 10)) + .foregroundColor(Color(hex: "8888aa")) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20) + .fill(Color(hex: "12122a").opacity(0.7)) + ) + ) + .shadow(color: .black.opacity(0.15), radius: 8, y: 4) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 15) + .onAppear { + withAnimation(.easeOut(duration: 0.5).delay(0.25)) { + appeared = true + } + } + } + + private func formatSteps(_ n: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.groupingSeparator = " " + return formatter.string(from: NSNumber(value: n)) ?? "\(n)" + } +} + +// MARK: - Insights Card + +struct InsightsCard: View { + let readiness: ReadinessResponse? + let latest: LatestHealthResponse? + @State private var appeared = false + + var insights: [(icon: String, text: String, color: Color)] { + var result: [(String, String, Color)] = [] + + if let r = readiness { + if r.score >= 80 { + result.append(("bolt.fill", "Отличный день для тренировки!", Color(hex: "00d4aa"))) + } else if r.score < 60 { + result.append(("bed.double.fill", "Сегодня лучше отдохнуть", Color(hex: "ff4757"))) + } + } + + if let sleep = latest?.sleep?.totalSleep, sleep < 7 { + result.append(("moon.zzz.fill", "Мало сна — постарайся лечь раньше", Color(hex: "7c3aed"))) + } + + if let hrv = latest?.hrv?.avg, hrv > 50 { + result.append(("heart.fill", "HRV в норме — хороший знак", Color(hex: "00d4aa"))) + } else if let hrv = latest?.hrv?.avg, hrv > 0 { + result.append(("exclamationmark.triangle.fill", "HRV ниже нормы — следи за стрессом", Color(hex: "ffa502"))) + } + + if let steps = latest?.steps?.total, steps > 0 && steps < 5000 { + result.append(("figure.walk", "Мало шагов — прогуляйся!", Color(hex: "ffa502"))) + } + + if result.isEmpty { + result.append(("sparkles", "Данные обновятся после синхронизации", Color(hex: "8888aa"))) + } + + return result + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + GradientIcon(icon: "lightbulb.fill", colors: [Color(hex: "ffa502"), Color(hex: "ff6348")]) + Text("Инсайты") + .font(.headline.weight(.semibold)) + .foregroundColor(.white) + Spacer() + } + + ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in + HStack(spacing: 12) { + Image(systemName: insight.icon) + .font(.subheadline) + .foregroundColor(insight.color) + .frame(width: 24) + + Text(insight.text) + .font(.subheadline) + .foregroundColor(.white.opacity(0.85)) + .lineLimit(2) + } + } + } + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20) + .fill(Color(hex: "12122a").opacity(0.7)) + ) + ) + .shadow(color: .black.opacity(0.2), radius: 10, y: 5) + .padding(.horizontal) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 20) + .onAppear { + withAnimation(.easeOut(duration: 0.5).delay(0.3)) { + appeared = true + } } - .padding(16).background(Color.white.opacity(0.05)).cornerRadius(16) } } diff --git a/PulseHealth/Views/ReadinessCardView.swift b/PulseHealth/Views/ReadinessCardView.swift index e13cd8b..94b0eb0 100644 --- a/PulseHealth/Views/ReadinessCardView.swift +++ b/PulseHealth/Views/ReadinessCardView.swift @@ -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 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 { - 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) + // 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")) } } - 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 { - 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) + 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 } } - .padding(24).background(Color.white.opacity(0.05)).cornerRadius(20).padding(.horizontal) } } +// 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) } } } diff --git a/PulseHealth/Views/ToastView.swift b/PulseHealth/Views/ToastView.swift new file mode 100644 index 0000000..9f02f5a --- /dev/null +++ b/PulseHealth/Views/ToastView.swift @@ -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, message: String, isSuccess: Bool) -> some View { + modifier(ToastModifier(isShowing: isShowing, message: message, isSuccess: isSuccess)) + } +} diff --git a/PulseHealth/Views/WeeklyChartView.swift b/PulseHealth/Views/WeeklyChartView.swift new file mode 100644 index 0000000..e4e1c80 --- /dev/null +++ b/PulseHealth/Views/WeeklyChartView.swift @@ -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) + } + } +}