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:
Cosmo
2026-03-25 11:13:20 +00:00
parent 14fcf7f770
commit 1eafeec5fe
8 changed files with 1230 additions and 105 deletions

View File

@@ -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? }

View File

@@ -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() }
func requestAuthorization() async throws { private let typesToRead: Set<HKObjectType> = [
let typesToRead: Set<HKObjectType> = [
HKQuantityType(.heartRate), HKQuantityType(.heartRate),
HKQuantityType(.restingHeartRate), HKQuantityType(.restingHeartRate),
HKQuantityType(.heartRateVariabilitySDNN), HKQuantityType(.heartRateVariabilitySDNN),
HKQuantityType(.stepCount), HKQuantityType(.stepCount),
HKQuantityType(.activeEnergyBurned), HKQuantityType(.activeEnergyBurned),
HKQuantityType(.oxygenSaturation), HKQuantityType(.oxygenSaturation),
HKQuantityType(.distanceWalkingRunning),
HKCategoryType(.sleepAnalysis), HKCategoryType(.sleepAnalysis),
] ]
func requestAuthorization() async throws {
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]]
])
} }
healthStore.execute(q)
// 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 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)"
} }
} }
} }

View File

@@ -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: "Пульс покоя", // MARK: - Metrics Grid
value: "\(Int(rhr.value ?? 0)) уд/мин", subtitle: "Resting HR", metricsGrid
color: Color(hex: "ff6b6b"))
// MARK: - Weekly Chart
if !heatmapData.isEmpty {
WeeklyChartCard(heatmapData: heatmapData)
} }
if let hrv = l.hrv {
MetricCardView(icon: "waveform.path.ecg", title: "HRV", // MARK: - Insights
value: "\(Int(hrv.avg ?? 0)) мс", subtitle: "Вариабельность", InsightsCard(readiness: readiness, latest: latest)
color: Color(hex: "00d4aa"))
} Spacer(minLength: 30)
if let steps = l.steps {
MetricCardView(icon: "figure.walk", title: "Шаги",
value: "\(steps.total ?? 0)", subtitle: "Сегодня",
color: Color(hex: "ffa500"))
}
}.padding(.horizontal)
}
}
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
}
}
} }

View File

@@ -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) {

View File

@@ -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)
} }
} }

View File

@@ -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 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 { 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)
} }
} }
} }

View 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))
}
}

View 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)
}
}
}