feat: major app overhaul — API fixes, glassmorphism UI, health dashboard, notifications
API Integration: - Fix logHabit: send "date" instead of "completed_at" - Fix FinanceCategory: "icon" → "emoji" to match API - Fix task priorities: remove level 4, keep 1-3 matching API - Fix habit frequencies: map monthly/interval → "custom" for API - Add token refresh (401 → auto retry with new token) - Add proper error handling (remove try? in save functions, show errors in UI) - Add date field to savings transactions - Add MonthlyPaymentDetail and OverduePayment models - Fix habit completedToday: compute on client from logs (API doesn't return it) - Filter habits by day of week on client (daily/weekly/monthly/interval) Design System (glassmorphism): - New DesignSystem.swift: Theme colors, GlassCard modifier, GlowIcon, GlowStatCard - Custom tab bar with per-tab glow colors (VStack layout, not ZStack overlay) - Deep dark background #06060f across all views - Glass cards with gradient fill + stroke throughout app - App icon: glassmorphism style with teal glow Health Dashboard: - Compact ReadinessBanner with recommendation text - 8 metric tiles: sleep, HR, HRV, steps, SpO2, respiratory rate, energy, distance - Each tile with status indicator (good/ok/bad) and hint text - Heart rate card (min/avg/max) - Weekly trends card (averages) - Recovery score (weighted: 40% sleep, 35% HRV, 25% RHR) - Tips card with actionable recommendations - Sleep detail view with hypnogram (step chart of phases) - Sleep segments timeline from HealthKit (deep/rem/core/awake with exact times) - Line chart replacing bar chart for weekly data - Collect respiratory_rate and sleep phases with timestamps from HealthKit - Background sync every ~30min via BGProcessingTask Notifications: - NotificationService for local push notifications - Morning/evening reminders with native DatePicker (wheel) - Payment reminders: 5 days, 1 day, and day-of for recurring savings - Notification settings in Settings tab UI Fixes: - Fix color picker overflow: HStack → LazyVGrid 5 columns - Fix sheet headers: shorter text, proper padding - Fix task/habit toggle: separate tap zones (checkbox vs edit) - Fix deprecated onChange syntax for iOS 17+ - Savings overview: real monthly payments and detailed overdues from API - Settings: timezone as Menu picker, removed Telegram/server notifications sections - All sheets use .presentationDetents([.large]) Config: - project.yml: real DEVELOPMENT_TEAM, HealthKit + BackgroundModes capabilities - Info.plist: BGTaskScheduler + UIBackgroundModes - Assets.xcassets with AppIcon - CLAUDE.md project documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,212 +8,523 @@ struct HealthView: View {
|
||||
@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 "Доброй ночи"
|
||||
}
|
||||
}
|
||||
@State private var showSleepDetail = false
|
||||
|
||||
var dateString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "ru_RU")
|
||||
formatter.dateFormat = "d MMMM, EEEE"
|
||||
return formatter.string(from: Date())
|
||||
let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU"); f.dateFormat = "d MMMM, EEEE"
|
||||
return f.string(from: Date())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(hex: "0a0a1a")
|
||||
.ignoresSafeArea()
|
||||
|
||||
Color(hex: "06060f").ignoresSafeArea()
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 20) {
|
||||
VStack(spacing: 16) {
|
||||
// MARK: - Header
|
||||
headerView
|
||||
.padding(.top, 8)
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Здоровье").font(.title2.bold()).foregroundColor(.white)
|
||||
Text(dateString).font(.subheadline).foregroundColor(Theme.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Button { Task { await syncHealthKit() } } label: {
|
||||
ZStack {
|
||||
Circle().fill(Color(hex: "1a1a3e")).frame(width: 42, height: 42)
|
||||
if healthKit.isSyncing { ProgressView().tint(Theme.teal).scaleEffect(0.8) }
|
||||
else { Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 16, weight: .medium)).foregroundColor(Theme.teal) }
|
||||
}
|
||||
}.disabled(healthKit.isSyncing)
|
||||
}
|
||||
.padding(.horizontal).padding(.top, 8)
|
||||
|
||||
if isLoading {
|
||||
loadingView
|
||||
ProgressView().tint(Theme.teal).padding(.top, 60)
|
||||
} else {
|
||||
// MARK: - Readiness
|
||||
if let r = readiness {
|
||||
ReadinessCardView(readiness: r)
|
||||
if let r = readiness { ReadinessBanner(readiness: r) }
|
||||
|
||||
// MARK: - Core Metrics 2x2
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
|
||||
Button { showSleepDetail = true } label: {
|
||||
HealthMetricTile(icon: "moon.fill", title: "Сон",
|
||||
value: String(format: "%.1f", latest?.sleep?.totalSleep ?? 0), unit: "ч",
|
||||
status: sleepStatus, color: Theme.purple,
|
||||
hint: "Норма 7-9ч. Нажми для деталей")
|
||||
}.buttonStyle(.plain)
|
||||
|
||||
HealthMetricTile(icon: "heart.fill", title: "Пульс покоя",
|
||||
value: "\(Int(latest?.restingHeartRate?.value ?? 0))", unit: "уд/м",
|
||||
status: rhrStatus, color: Theme.red,
|
||||
hint: "Чем ниже, тем лучше. Норма 50-70")
|
||||
|
||||
HealthMetricTile(icon: "waveform.path.ecg", title: "HRV",
|
||||
value: "\(Int(latest?.hrv?.avg ?? 0))", unit: "мс",
|
||||
status: hrvStatus, color: Theme.teal,
|
||||
hint: "Вариабельность пульса. Выше = лучше")
|
||||
|
||||
HealthMetricTile(icon: "figure.walk", title: "Шаги",
|
||||
value: fmtNum(latest?.steps?.total ?? 0), unit: "",
|
||||
status: stepsStatus, color: Theme.orange,
|
||||
hint: "Цель: 8 000 шагов в день")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// MARK: - Secondary Metrics
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
|
||||
if let spo2 = latest?.bloodOxygen, (spo2.avg ?? 0) > 0 {
|
||||
HealthMetricTile(icon: "lungs.fill", title: "Кислород",
|
||||
value: "\(Int(spo2.avg ?? 0))", unit: "%",
|
||||
status: spo2Status, color: Theme.blue,
|
||||
hint: "Насыщение крови O₂. Норма ≥ 96%")
|
||||
}
|
||||
if let rr = latest?.respiratoryRate, (rr.avg ?? 0) > 0 {
|
||||
HealthMetricTile(icon: "wind", title: "Дыхание",
|
||||
value: String(format: "%.0f", rr.avg ?? 0), unit: "вд/м",
|
||||
status: rrStatus, color: Theme.indigo,
|
||||
hint: "Частота дыхания. Норма 12-20")
|
||||
}
|
||||
if let energy = latest?.activeEnergy, (energy.total ?? 0) > 0 {
|
||||
HealthMetricTile(icon: "flame.fill", title: "Энергия",
|
||||
value: "\(energy.total ?? 0)", unit: energy.units == "kJ" ? "кДж" : "ккал",
|
||||
status: energyStatus, color: Color(hex: "ff6348"),
|
||||
hint: "Активные калории за день")
|
||||
}
|
||||
if let dist = latest?.distance, (dist.total ?? 0) > 0 {
|
||||
HealthMetricTile(icon: "map.fill", title: "Дистанция",
|
||||
value: String(format: "%.1f", (dist.total ?? 0) / 1000), unit: "км",
|
||||
status: distStatus, color: Theme.green,
|
||||
hint: "Пройдено пешком и бегом")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// MARK: - Heart Rate Card
|
||||
if let hr = latest?.heartRate, (hr.avg ?? 0) > 0 {
|
||||
HeartRateCard(hr: hr, rhr: latest?.restingHeartRate)
|
||||
}
|
||||
|
||||
// MARK: - Metrics Grid
|
||||
metricsGrid
|
||||
// MARK: - Weekly Trends
|
||||
if heatmapData.count >= 2 {
|
||||
WeeklyTrendsCard(heatmapData: heatmapData)
|
||||
}
|
||||
|
||||
// MARK: - Weekly Chart
|
||||
if !heatmapData.isEmpty {
|
||||
WeeklyChartCard(heatmapData: heatmapData)
|
||||
}
|
||||
|
||||
// MARK: - Insights
|
||||
InsightsCard(readiness: readiness, latest: latest)
|
||||
// MARK: - Recovery Score
|
||||
RecoveryCard(sleep: latest?.sleep, hrv: latest?.hrv, rhr: latest?.restingHeartRate)
|
||||
|
||||
// MARK: - Tips
|
||||
TipsCard(readiness: readiness, latest: latest)
|
||||
|
||||
Spacer(minLength: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await loadData(refresh: true)
|
||||
}
|
||||
.refreshable { await loadData(refresh: true) }
|
||||
}
|
||||
.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("Здоровье 🫀")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.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) {
|
||||
.sheet(isPresented: $showSleepDetail) {
|
||||
if let sleep = latest?.sleep {
|
||||
SleepCard(sleep: sleep)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
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")]
|
||||
)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
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")]
|
||||
)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
if let steps = latest?.steps {
|
||||
StepsCard(steps: steps.total ?? 0)
|
||||
.frame(maxHeight: .infinity)
|
||||
SleepDetailView(sleep: sleep).presentationDetents([.large]).presentationBackground(Color(hex: "06060f"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.task {
|
||||
if healthKit.isAvailable { try? await healthKit.requestAuthorization() }
|
||||
await loadData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Load Data
|
||||
// MARK: - Statuses
|
||||
|
||||
var sleepStatus: MetricStatus {
|
||||
guard let s = latest?.sleep?.totalSleep, s > 0 else { return .noData }
|
||||
if s >= 7.5 { return .good("Отличный сон") }
|
||||
if s >= 6 { return .ok("Можно лучше") }
|
||||
return .bad("Мало сна")
|
||||
}
|
||||
var rhrStatus: MetricStatus {
|
||||
guard let v = latest?.restingHeartRate?.value, v > 0 else { return .noData }
|
||||
if v <= 65 { return .good("Отлично") }
|
||||
if v <= 80 { return .ok("Нормально") }
|
||||
return .bad("Повышенный")
|
||||
}
|
||||
var hrvStatus: MetricStatus {
|
||||
guard let v = latest?.hrv?.avg, v > 0 else { return .noData }
|
||||
if v >= 50 { return .good("Хорошее восстановление") }
|
||||
if v >= 30 { return .ok("Средний уровень") }
|
||||
return .bad("Стресс / усталость")
|
||||
}
|
||||
var stepsStatus: MetricStatus {
|
||||
guard let s = latest?.steps?.total, s > 0 else { return .noData }
|
||||
if s >= 8000 { return .good("Цель достигнута") }
|
||||
if s >= 5000 { return .ok("\(8000 - s) до цели") }
|
||||
return .bad("Мало движения")
|
||||
}
|
||||
var spo2Status: MetricStatus {
|
||||
guard let v = latest?.bloodOxygen?.avg, v > 0 else { return .noData }
|
||||
if v >= 96 { return .good("Норма") }
|
||||
if v >= 93 { return .ok("Пониженный") }
|
||||
return .bad("Низкий!")
|
||||
}
|
||||
var rrStatus: MetricStatus {
|
||||
guard let v = latest?.respiratoryRate?.avg, v > 0 else { return .noData }
|
||||
if v >= 12 && v <= 20 { return .good("Норма") }
|
||||
return .ok("Отклонение")
|
||||
}
|
||||
var energyStatus: MetricStatus {
|
||||
guard let v = latest?.activeEnergy?.total, v > 0 else { return .noData }
|
||||
if v >= 300 { return .good("Активный день") }
|
||||
if v >= 150 { return .ok("Умеренно") }
|
||||
return .bad("Мало активности")
|
||||
}
|
||||
var distStatus: MetricStatus {
|
||||
guard let v = latest?.distance?.total, v > 0 else { return .noData }
|
||||
let km = v / 1000
|
||||
if km >= 5 { return .good("Отлично") }
|
||||
if km >= 2 { return .ok("Нормально") }
|
||||
return .bad("Мало")
|
||||
}
|
||||
|
||||
// MARK: - Data
|
||||
|
||||
func loadData(refresh: Bool = false) async {
|
||||
if !refresh { isLoading = true }
|
||||
|
||||
async let r = HealthAPIService.shared.getReadiness()
|
||||
async let l = HealthAPIService.shared.getLatest()
|
||||
async let h = HealthAPIService.shared.getHeatmap(days: 7)
|
||||
|
||||
readiness = try? await r
|
||||
latest = try? await l
|
||||
heatmapData = (try? await h) ?? []
|
||||
|
||||
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 healthKit.isAvailable else { showToastMsg("HealthKit недоступен", success: false); return }
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
|
||||
do {
|
||||
try await healthKit.syncToServer(apiKey: authManager.healthApiKey)
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
showToastMessage("Данные синхронизированы ✓", success: true)
|
||||
showToastMsg("Синхронизировано", success: true)
|
||||
await loadData()
|
||||
} catch {
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
showToastMessage(error.localizedDescription, success: false)
|
||||
showToastMsg(error.localizedDescription, success: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func showToastMessage(_ message: String, success: Bool) {
|
||||
toastMessage = message
|
||||
toastSuccess = success
|
||||
withAnimation {
|
||||
showToast = true
|
||||
private func showToastMsg(_ msg: String, success: Bool) {
|
||||
toastMessage = msg; toastSuccess = success; withAnimation { showToast = true }
|
||||
}
|
||||
|
||||
private func fmtNum(_ n: Int) -> String {
|
||||
let f = NumberFormatter(); f.numberStyle = .decimal; f.groupingSeparator = " "
|
||||
return f.string(from: NSNumber(value: n)) ?? "\(n)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MetricStatus
|
||||
|
||||
enum MetricStatus {
|
||||
case good(String), ok(String), bad(String), noData
|
||||
var text: String {
|
||||
switch self { case .good(let s), .ok(let s), .bad(let s): return s; case .noData: return "Нет данных" }
|
||||
}
|
||||
var color: Color {
|
||||
switch self { case .good: return Theme.teal; case .ok: return Theme.orange; case .bad: return Theme.red; case .noData: return Theme.textSecondary }
|
||||
}
|
||||
var icon: String {
|
||||
switch self { case .good: return "arrow.up.right"; case .ok: return "minus"; case .bad: return "arrow.down.right"; case .noData: return "questionmark" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Readiness Banner
|
||||
|
||||
struct ReadinessBanner: View {
|
||||
let readiness: ReadinessResponse
|
||||
var statusColor: Color {
|
||||
if readiness.score >= 80 { return Theme.teal }
|
||||
if readiness.score >= 60 { return Theme.orange }
|
||||
return Theme.red
|
||||
}
|
||||
var statusText: String {
|
||||
if readiness.score >= 80 { return "Отличный день для активности" }
|
||||
if readiness.score >= 60 { return "Умеренная нагрузка будет в самый раз" }
|
||||
return "Лучше отдохнуть и восстановиться"
|
||||
}
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.08), lineWidth: 8).frame(width: 72, height: 72)
|
||||
Circle().trim(from: 0, to: CGFloat(readiness.score) / 100)
|
||||
.stroke(statusColor, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||
.frame(width: 72, height: 72).rotationEffect(.degrees(-90))
|
||||
.shadow(color: statusColor.opacity(0.4), radius: 6)
|
||||
Text("\(readiness.score)").font(.system(size: 22, weight: .bold, design: .rounded)).foregroundColor(statusColor)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Готовность").font(.subheadline).foregroundColor(Theme.textSecondary)
|
||||
Text(statusText).font(.callout.weight(.medium)).foregroundColor(.white).lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16).glassCard(cornerRadius: 18).padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Health Metric Tile
|
||||
|
||||
struct HealthMetricTile: View {
|
||||
let icon: String; let title: String; let value: String; let unit: String
|
||||
let status: MetricStatus; let color: Color
|
||||
var hint: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
GlowIcon(systemName: icon, color: color, size: 32, iconSize: .caption)
|
||||
Spacer()
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: status.icon).font(.system(size: 9, weight: .bold))
|
||||
Text(status.text).font(.system(size: 10, weight: .medium))
|
||||
}.foregroundColor(status.color)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
||||
Text(value).font(.title2.bold().monospacedDigit()).foregroundColor(.white)
|
||||
Text(unit).font(.caption.bold()).foregroundColor(Theme.textSecondary)
|
||||
}
|
||||
Text(title).font(.caption).foregroundColor(Theme.textSecondary)
|
||||
if let h = hint {
|
||||
Text(h).font(.system(size: 9)).foregroundColor(Theme.textSecondary.opacity(0.7)).lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(14).frame(maxWidth: .infinity, alignment: .leading).frame(minHeight: 120)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Heart Rate Card
|
||||
|
||||
struct HeartRateCard: View {
|
||||
let hr: HeartRateData
|
||||
let rhr: RestingHRData?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 8) {
|
||||
GradientIcon(icon: "heart.fill", colors: [Theme.red, Theme.pink])
|
||||
Text("Пульс за день").font(.headline).foregroundColor(.white)
|
||||
Spacer()
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
HRStatBox(label: "Мин", value: "\(hr.min ?? 0)", color: Theme.teal)
|
||||
Divider().frame(height: 40).background(Color.white.opacity(0.1))
|
||||
HRStatBox(label: "Средний", value: "\(hr.avg ?? 0)", color: .white)
|
||||
Divider().frame(height: 40).background(Color.white.opacity(0.1))
|
||||
HRStatBox(label: "Макс", value: "\(hr.max ?? 0)", color: Theme.red)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.04)))
|
||||
|
||||
if let rhr = rhr?.value, rhr > 0 {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(Theme.purple).frame(width: 6, height: 6)
|
||||
Text("Пульс покоя: \(Int(rhr)) уд/мин").font(.caption).foregroundColor(Theme.textSecondary)
|
||||
Spacer()
|
||||
Text(rhr <= 65 ? "Отлично" : rhr <= 80 ? "Норма" : "Высокий")
|
||||
.font(.caption.bold()).foregroundColor(rhr <= 65 ? Theme.teal : rhr <= 80 ? Theme.orange : Theme.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16).glassCard(cornerRadius: 18).padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
struct HRStatBox: View {
|
||||
let label: String; let value: String; let color: Color
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(value).font(.title3.bold().monospacedDigit()).foregroundColor(color)
|
||||
Text(label).font(.caption2).foregroundColor(Theme.textSecondary)
|
||||
}.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Weekly Trends Card
|
||||
|
||||
struct WeeklyTrendsCard: View {
|
||||
let heatmapData: [HeatmapEntry]
|
||||
|
||||
var avgSleep: Double {
|
||||
let vals = heatmapData.compactMap(\.sleep).filter { $0 > 0 }
|
||||
return vals.isEmpty ? 0 : vals.reduce(0, +) / Double(vals.count)
|
||||
}
|
||||
var avgHRV: Double {
|
||||
let vals = heatmapData.compactMap(\.hrv).filter { $0 > 0 }
|
||||
return vals.isEmpty ? 0 : vals.reduce(0, +) / Double(vals.count)
|
||||
}
|
||||
var avgRHR: Double {
|
||||
let vals = heatmapData.compactMap(\.rhr).filter { $0 > 0 }
|
||||
return vals.isEmpty ? 0 : vals.reduce(0, +) / Double(vals.count)
|
||||
}
|
||||
var avgSteps: Int {
|
||||
let vals = heatmapData.compactMap(\.steps).filter { $0 > 0 }
|
||||
return vals.isEmpty ? 0 : vals.reduce(0, +) / vals.count
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 8) {
|
||||
GradientIcon(icon: "chart.line.uptrend.xyaxis", colors: [Theme.indigo, Theme.blue])
|
||||
Text("Средние за неделю").font(.headline).foregroundColor(.white)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||
TrendItem(icon: "moon.fill", label: "Сон", value: String(format: "%.1f ч", avgSleep), color: Theme.purple)
|
||||
TrendItem(icon: "waveform.path.ecg", label: "HRV", value: "\(Int(avgHRV)) мс", color: Theme.teal)
|
||||
TrendItem(icon: "heart.fill", label: "Пульс покоя", value: "\(Int(avgRHR)) уд/м", color: Theme.red)
|
||||
TrendItem(icon: "figure.walk", label: "Шаги", value: "\(avgSteps)", color: Theme.orange)
|
||||
}
|
||||
}
|
||||
.padding(16).glassCard(cornerRadius: 18).padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
struct TrendItem: View {
|
||||
let icon: String; let label: String; let value: String; let color: Color
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon).font(.caption).foregroundColor(color).frame(width: 18)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(value).font(.callout.bold().monospacedDigit()).foregroundColor(.white)
|
||||
Text(label).font(.caption2).foregroundColor(Theme.textSecondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recovery Card
|
||||
|
||||
struct RecoveryCard: View {
|
||||
let sleep: SleepData?
|
||||
let hrv: HRVData?
|
||||
let rhr: RestingHRData?
|
||||
|
||||
var sleepScore: Double {
|
||||
guard let s = sleep?.totalSleep, s > 0 else { return 0 }
|
||||
return min(s / 8.0, 1.0) * 100
|
||||
}
|
||||
var hrvScore: Double {
|
||||
guard let v = hrv?.avg, v > 0 else { return 0 }
|
||||
return min(v / 60.0, 1.0) * 100
|
||||
}
|
||||
var rhrScore: Double {
|
||||
guard let v = rhr?.value, v > 0 else { return 0 }
|
||||
if v <= 55 { return 100 }
|
||||
if v <= 65 { return 80 }
|
||||
if v <= 75 { return 60 }
|
||||
return max(40 - (v - 75), 10)
|
||||
}
|
||||
var recoveryScore: Int {
|
||||
let scores = [sleepScore, hrvScore, rhrScore].filter { $0 > 0 }
|
||||
guard !scores.isEmpty else { return 0 }
|
||||
// Weighted: 40% sleep, 35% HRV, 25% RHR
|
||||
let w = sleepScore * 0.4 + hrvScore * 0.35 + rhrScore * 0.25
|
||||
return Int(w)
|
||||
}
|
||||
var recoveryColor: Color {
|
||||
if recoveryScore >= 75 { return Theme.teal }
|
||||
if recoveryScore >= 50 { return Theme.orange }
|
||||
return Theme.red
|
||||
}
|
||||
var recoveryText: String {
|
||||
if recoveryScore >= 75 { return "Организм хорошо восстановился" }
|
||||
if recoveryScore >= 50 { return "Среднее восстановление" }
|
||||
if recoveryScore > 0 { return "Тело ещё не восстановилось" }
|
||||
return "Недостаточно данных"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 8) {
|
||||
GradientIcon(icon: "battery.100.bolt", colors: [Theme.teal, Theme.green])
|
||||
Text("Восстановление").font(.headline).foregroundColor(.white)
|
||||
Spacer()
|
||||
Text("\(recoveryScore)%").font(.title3.bold()).foregroundColor(recoveryColor)
|
||||
}
|
||||
|
||||
Text(recoveryText).font(.subheadline).foregroundColor(.white.opacity(0.7))
|
||||
|
||||
// Factor bars
|
||||
VStack(spacing: 8) {
|
||||
RecoveryFactor(name: "Сон", score: sleepScore, color: Theme.purple)
|
||||
RecoveryFactor(name: "HRV", score: hrvScore, color: Theme.teal)
|
||||
RecoveryFactor(name: "Пульс покоя", score: rhrScore, color: Theme.red)
|
||||
}
|
||||
}
|
||||
.padding(16).glassCard(cornerRadius: 18).padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
struct RecoveryFactor: View {
|
||||
let name: String; let score: Double; let color: Color
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(name).font(.caption).foregroundColor(Theme.textSecondary).frame(width: 80, alignment: .leading)
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.06))
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(color)
|
||||
.frame(width: geo.size.width * CGFloat(score / 100))
|
||||
.shadow(color: color.opacity(0.3), radius: 3)
|
||||
}
|
||||
}.frame(height: 6)
|
||||
Text("\(Int(score))%").font(.caption.bold().monospacedDigit()).foregroundColor(.white.opacity(0.6)).frame(width: 32, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tips Card
|
||||
|
||||
struct TipsCard: View {
|
||||
let readiness: ReadinessResponse?; let latest: LatestHealthResponse?
|
||||
var tips: [(icon: String, text: String, color: Color)] {
|
||||
var r: [(String, String, Color)] = []
|
||||
if let s = readiness?.score {
|
||||
if s >= 80 { r.append(("bolt.fill", "Высокая готовность — идеальный день для тренировки", Theme.teal)) }
|
||||
else if s < 60 { r.append(("bed.double.fill", "Низкая готовность — сфокусируйся на восстановлении", Theme.red)) }
|
||||
}
|
||||
if let s = latest?.sleep?.totalSleep {
|
||||
if s < 6 { r.append(("moon.zzz.fill", "Критически мало сна. Ложись раньше", Theme.purple)) }
|
||||
else if s < 7 { r.append(("moon.fill", "Старайся спать 7-9 часов", Theme.purple)) }
|
||||
}
|
||||
if let v = latest?.hrv?.avg, v > 0, v < 30 { r.append(("exclamationmark.triangle.fill", "Низкий HRV — возможен стресс", Theme.orange)) }
|
||||
if let s = latest?.steps?.total, s > 0, s < 5000 { r.append(("figure.walk", "15 минут прогулки улучшат самочувствие", Theme.orange)) }
|
||||
if let spo2 = latest?.bloodOxygen?.avg, spo2 > 0, spo2 < 95 { r.append(("lungs.fill", "Кислород ниже нормы — дыши глубже", Theme.blue)) }
|
||||
if r.isEmpty { r.append(("sparkles", "Все показатели в норме — так держать!", Theme.teal)) }
|
||||
return r
|
||||
}
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "lightbulb.fill").foregroundColor(Theme.orange)
|
||||
Text("Рекомендации").font(.headline).foregroundColor(.white)
|
||||
}
|
||||
ForEach(Array(tips.enumerated()), id: \.offset) { _, tip in
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: tip.icon).font(.caption).foregroundColor(tip.color).frame(width: 20).padding(.top, 2)
|
||||
Text(tip.text).font(.subheadline).foregroundColor(.white.opacity(0.85)).lineLimit(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16).glassCard(cornerRadius: 18).padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,24 +9,20 @@ struct GradientIcon: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(colors.first?.opacity(0.25) ?? Color.clear)
|
||||
.frame(width: size * 1.2, height: size * 1.2)
|
||||
.blur(radius: 10)
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: colors.map { $0.opacity(0.2) },
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
LinearGradient(colors: colors.map { $0.opacity(0.15) },
|
||||
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
|
||||
)
|
||||
LinearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -41,79 +37,26 @@ struct MetricCardView: View {
|
||||
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) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
Text(value).font(.title2.bold()).foregroundColor(.white)
|
||||
Text(title).font(.subheadline.weight(.medium)).foregroundColor(.white.opacity(0.7))
|
||||
if !subtitle.isEmpty {
|
||||
Text(subtitle).font(.caption).foregroundColor(Theme.textSecondary).lineLimit(2)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
.glassCard(cornerRadius: 20)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.offset(y: appeared ? 0 : 15)
|
||||
.onAppear {
|
||||
withAnimation(.easeOut(duration: 0.5).delay(0.1)) {
|
||||
appeared = true
|
||||
}
|
||||
}
|
||||
.onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.1)) { appeared = true } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,90 +67,32 @@ struct SleepCard: View {
|
||||
@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) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
GradientIcon(icon: "moon.fill", colors: [Color(hex: "7c3aed"), Color(hex: "a78bfa")])
|
||||
GradientIcon(icon: "moon.fill", colors: [Theme.purple, Color(hex: "a78bfa")])
|
||||
Spacer()
|
||||
}
|
||||
Text(String(format: "%.1f ч", totalHours)).font(.title2.bold()).foregroundColor(.white)
|
||||
Text("Сон").font(.subheadline.weight(.medium)).foregroundColor(.white.opacity(0.7))
|
||||
|
||||
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(Color.white.opacity(0.08))
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color(hex: "7c3aed"), Color(hex: "a78bfa")],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.fill(LinearGradient(colors: [Theme.purple, Color(hex: "a78bfa")], startPoint: .leading, endPoint: .trailing))
|
||||
.frame(width: geo.size.width * min(CGFloat(totalHours / 9.0), 1.0))
|
||||
.shadow(color: Theme.purple.opacity(0.5), radius: 4, y: 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)
|
||||
.glassCard(cornerRadius: 20)
|
||||
.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)
|
||||
}
|
||||
.onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.15)) { appeared = true } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,68 +104,40 @@ struct StepsCard: View {
|
||||
@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) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
GradientIcon(icon: "figure.walk", colors: [Color(hex: "ffa502"), Color(hex: "ff6348")])
|
||||
GradientIcon(icon: "figure.walk", colors: [Theme.orange, Color(hex: "ff6348")])
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text(formatSteps(steps))
|
||||
.font(.title2.bold())
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text("Шаги")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
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(Color.white.opacity(0.08))
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color(hex: "ffa502"), Color(hex: "ff6348")],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.fill(LinearGradient(colors: [Theme.orange, Color(hex: "ff6348")], startPoint: .leading, endPoint: .trailing))
|
||||
.frame(width: geo.size.width * min(CGFloat(progress), 1.0))
|
||||
.shadow(color: Theme.orange.opacity(0.5), radius: 4, y: 0)
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
|
||||
Text("\(percent)% от цели")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(Color(hex: "8888aa"))
|
||||
Text("\(Int(progress * 100))% от цели")
|
||||
.font(.system(size: 10)).foregroundColor(Theme.textSecondary)
|
||||
}
|
||||
.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)
|
||||
.glassCard(cornerRadius: 20)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.offset(y: appeared ? 0 : 15)
|
||||
.onAppear {
|
||||
withAnimation(.easeOut(duration: 0.5).delay(0.25)) {
|
||||
appeared = true
|
||||
}
|
||||
}
|
||||
.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)"
|
||||
let f = NumberFormatter(); f.numberStyle = .decimal; f.groupingSeparator = " "
|
||||
return f.string(from: NSNumber(value: n)) ?? "\(n)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,81 +146,121 @@ struct StepsCard: View {
|
||||
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 r.score >= 80 { result.append(("bolt.fill", "Отличный день для тренировки!", Theme.teal)) }
|
||||
else if r.score < 60 { result.append(("bed.double.fill", "Сегодня лучше отдохнуть", Theme.red)) }
|
||||
}
|
||||
|
||||
if let sleep = latest?.sleep?.totalSleep, sleep < 7 {
|
||||
result.append(("moon.zzz.fill", "Мало сна — постарайся лечь раньше", Color(hex: "7c3aed")))
|
||||
result.append(("moon.zzz.fill", "Мало сна — постарайся лечь раньше", Theme.purple))
|
||||
}
|
||||
|
||||
if let hrv = latest?.hrv?.avg, hrv > 50 {
|
||||
result.append(("heart.fill", "HRV в норме — хороший знак", Color(hex: "00d4aa")))
|
||||
result.append(("heart.fill", "HRV в норме — хороший знак", Theme.teal))
|
||||
} else if let hrv = latest?.hrv?.avg, hrv > 0 {
|
||||
result.append(("exclamationmark.triangle.fill", "HRV ниже нормы — следи за стрессом", Color(hex: "ffa502")))
|
||||
result.append(("exclamationmark.triangle.fill", "HRV ниже нормы — следи за стрессом", Theme.orange))
|
||||
}
|
||||
|
||||
if let steps = latest?.steps?.total, steps > 0 && steps < 5000 {
|
||||
result.append(("figure.walk", "Мало шагов — прогуляйся!", Color(hex: "ffa502")))
|
||||
result.append(("figure.walk", "Мало шагов — прогуляйся!", Theme.orange))
|
||||
}
|
||||
|
||||
if result.isEmpty {
|
||||
result.append(("sparkles", "Данные обновятся после синхронизации", Color(hex: "8888aa")))
|
||||
result.append(("sparkles", "Данные обновятся после синхронизации", Theme.textSecondary))
|
||||
}
|
||||
|
||||
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)
|
||||
GradientIcon(icon: "lightbulb.fill", colors: [Theme.orange, 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)
|
||||
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)
|
||||
.glassCard(cornerRadius: 20)
|
||||
.padding(.horizontal)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.offset(y: appeared ? 0 : 20)
|
||||
.onAppear {
|
||||
withAnimation(.easeOut(duration: 0.5).delay(0.3)) {
|
||||
appeared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sleep Phases Card
|
||||
|
||||
struct SleepPhasesCard: View {
|
||||
let sleep: SleepData
|
||||
@State private var appeared = false
|
||||
|
||||
var total: Double { sleep.totalSleep ?? 0 }
|
||||
var deep: Double { sleep.deep ?? 0 }
|
||||
var rem: Double { sleep.rem ?? 0 }
|
||||
var core: Double { sleep.core ?? 0 }
|
||||
|
||||
var phases: [(name: String, value: Double, color: Color)] {
|
||||
[
|
||||
("Глубокий", deep, Theme.purple),
|
||||
("Быстрый (REM)", rem, Color(hex: "a78bfa")),
|
||||
("Базовый", core, Color(hex: "c4b5fd")),
|
||||
]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
GradientIcon(icon: "bed.double.fill", colors: [Theme.purple, Color(hex: "a78bfa")])
|
||||
Text("Фазы сна").font(.headline.weight(.semibold)).foregroundColor(.white)
|
||||
Spacer()
|
||||
Text(String(format: "%.1f ч", total))
|
||||
.font(.callout.bold()).foregroundColor(Theme.purple)
|
||||
}
|
||||
|
||||
// Stacked bar
|
||||
GeometryReader { geo in
|
||||
HStack(spacing: 2) {
|
||||
ForEach(phases, id: \.name) { phase in
|
||||
let fraction = total > 0 ? phase.value / total : 0
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(phase.color)
|
||||
.frame(width: max(geo.size.width * CGFloat(fraction), fraction > 0 ? 4 : 0))
|
||||
.shadow(color: phase.color.opacity(0.4), radius: 4, y: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 12)
|
||||
|
||||
// Phase details
|
||||
ForEach(phases, id: \.name) { phase in
|
||||
HStack(spacing: 12) {
|
||||
Circle().fill(phase.color).frame(width: 8, height: 8)
|
||||
Text(phase.name).font(.callout).foregroundColor(.white)
|
||||
Spacer()
|
||||
Text(fmtDuration(phase.value))
|
||||
.font(.callout.bold().monospacedDigit()).foregroundColor(phase.color)
|
||||
if total > 0 {
|
||||
Text("\(Int(phase.value / total * 100))%")
|
||||
.font(.caption).foregroundColor(Theme.textSecondary)
|
||||
.frame(width: 32, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 20)
|
||||
.padding(.horizontal)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.offset(y: appeared ? 0 : 15)
|
||||
.onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.2)) { appeared = true } }
|
||||
}
|
||||
|
||||
private func fmtDuration(_ h: Double) -> String {
|
||||
let hrs = Int(h)
|
||||
let mins = Int((h - Double(hrs)) * 60)
|
||||
if hrs > 0 { return "\(hrs)ч \(mins)м" }
|
||||
return "\(mins)м"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,152 +1,3 @@
|
||||
// ReadinessCardView — replaced by ReadinessBanner in HealthView.swift
|
||||
// This file is intentionally empty.
|
||||
import SwiftUI
|
||||
|
||||
struct ReadinessCardView: View {
|
||||
let readiness: ReadinessResponse
|
||||
@State private var animatedScore: CGFloat = 0
|
||||
@State private var appeared = false
|
||||
|
||||
var statusColor: Color {
|
||||
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 body: some View {
|
||||
VStack(spacing: 20) {
|
||||
// Score Ring
|
||||
ZStack {
|
||||
// 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 icon: String
|
||||
let score: Int
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
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: 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.7))
|
||||
.frame(width: 55, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
249
PulseHealth/Views/Health/SleepDetailView.swift
Normal file
249
PulseHealth/Views/Health/SleepDetailView.swift
Normal file
@@ -0,0 +1,249 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SleepDetailView: View {
|
||||
let sleep: SleepData
|
||||
@StateObject private var healthKit = HealthKitService()
|
||||
@State private var segments: [SleepSegment] = []
|
||||
@State private var isLoading = true
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var total: Double { sleep.totalSleep ?? 0 }
|
||||
var deep: Double { sleep.deep ?? 0 }
|
||||
var rem: Double { sleep.rem ?? 0 }
|
||||
var core: Double { sleep.core ?? 0 }
|
||||
|
||||
var phases: [(name: String, value: Double, color: Color, icon: String)] {
|
||||
[
|
||||
("Глубокий", deep, SleepPhaseType.deep.color, "moon.zzz.fill"),
|
||||
("REM", rem, SleepPhaseType.rem.color, "brain.head.profile"),
|
||||
("Базовый", core, SleepPhaseType.core.color, "moon.fill"),
|
||||
]
|
||||
}
|
||||
|
||||
var sleepStart: Date? { segments.first?.start }
|
||||
var sleepEnd: Date? { segments.last?.end }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(hex: "06060f").ignoresSafeArea()
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 20) {
|
||||
// Header
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2).foregroundColor(Theme.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Text("Анализ сна").font(.headline).foregroundColor(.white)
|
||||
Spacer()
|
||||
Color.clear.frame(width: 28)
|
||||
}
|
||||
.padding(.horizontal).padding(.top, 16)
|
||||
|
||||
// Total
|
||||
VStack(spacing: 8) {
|
||||
Text(String(format: "%.1f ч", total))
|
||||
.font(.system(size: 48, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Theme.purple)
|
||||
if let start = sleepStart, let end = sleepEnd {
|
||||
Text("\(fmt(start)) — \(fmt(end))")
|
||||
.font(.callout).foregroundColor(Theme.textSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
// Phase cards
|
||||
HStack(spacing: 12) {
|
||||
ForEach(phases, id: \.name) { phase in
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: phase.icon).font(.caption).foregroundColor(phase.color)
|
||||
Text(fmtDuration(phase.value)).font(.callout.bold().monospacedDigit()).foregroundColor(.white)
|
||||
Text(phase.name).font(.caption2).foregroundColor(Theme.textSecondary)
|
||||
if total > 0 {
|
||||
Text("\(Int(phase.value / total * 100))%").font(.caption2.bold()).foregroundColor(phase.color)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 12)
|
||||
.glassCard(cornerRadius: 14)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Hypnogram
|
||||
if isLoading {
|
||||
ProgressView().tint(Theme.purple).padding(.top, 20)
|
||||
} else if !segments.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Гипнограмма").font(.subheadline.bold()).foregroundColor(.white)
|
||||
HypnogramView(segments: segments)
|
||||
.frame(height: 180)
|
||||
}
|
||||
.padding(16)
|
||||
.glassCard(cornerRadius: 16)
|
||||
.padding(.horizontal)
|
||||
} else {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "moon.zzz").font(.title).foregroundColor(Theme.textSecondary)
|
||||
Text("График недоступен").font(.subheadline).foregroundColor(Theme.textSecondary)
|
||||
}.padding(.top, 20)
|
||||
}
|
||||
|
||||
// Stacked bar
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Распределение").font(.subheadline.bold()).foregroundColor(.white)
|
||||
GeometryReader { geo in
|
||||
HStack(spacing: 2) {
|
||||
ForEach(phases, id: \.name) { phase in
|
||||
let frac = total > 0 ? phase.value / total : 0
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(phase.color)
|
||||
.frame(width: max(geo.size.width * CGFloat(frac), frac > 0 ? 4 : 0))
|
||||
.shadow(color: phase.color.opacity(0.4), radius: 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 14)
|
||||
}
|
||||
.padding(16)
|
||||
.glassCard(cornerRadius: 16)
|
||||
.padding(.horizontal)
|
||||
|
||||
// Legend
|
||||
HStack(spacing: 16) {
|
||||
ForEach([SleepPhaseType.awake, .rem, .core, .deep], id: \.rawValue) { phase in
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(phase.color).frame(width: 8, height: 8)
|
||||
Text(phase.rawValue).font(.caption2).foregroundColor(Theme.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if healthKit.isAvailable {
|
||||
try? await healthKit.requestAuthorization()
|
||||
segments = await healthKit.fetchSleepSegments()
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private func fmt(_ date: Date) -> String {
|
||||
let f = DateFormatter(); f.dateFormat = "HH:mm"; return f.string(from: date)
|
||||
}
|
||||
private func fmtDuration(_ h: Double) -> String {
|
||||
let hrs = Int(h); let mins = Int((h - Double(hrs)) * 60)
|
||||
if hrs > 0 { return "\(hrs)ч \(mins)м" }
|
||||
return "\(mins)м"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hypnogram (sleep stages chart)
|
||||
|
||||
struct HypnogramView: View {
|
||||
let segments: [SleepSegment]
|
||||
|
||||
// Phase levels: awake=top, rem, core, deep=bottom
|
||||
private func yLevel(_ phase: SleepPhaseType) -> CGFloat {
|
||||
switch phase {
|
||||
case .awake: return 0.0
|
||||
case .rem: return 0.33
|
||||
case .core: return 0.66
|
||||
case .deep: return 1.0
|
||||
}
|
||||
}
|
||||
|
||||
private var timeStart: Date { segments.first?.start ?? Date() }
|
||||
private var timeEnd: Date { segments.last?.end ?? Date() }
|
||||
private var totalSpan: TimeInterval { max(timeEnd.timeIntervalSince(timeStart), 1) }
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let w = geo.size.width
|
||||
let chartH = geo.size.height - 36 // space for labels
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
// Grid lines + labels
|
||||
ForEach(0..<4, id: \.self) { i in
|
||||
let y = chartH * CGFloat(i) / 3.0
|
||||
Path { p in p.move(to: CGPoint(x: 0, y: y)); p.addLine(to: CGPoint(x: w, y: y)) }
|
||||
.stroke(Color.white.opacity(0.05), lineWidth: 1)
|
||||
|
||||
let labels = ["Пробуждение", "REM", "Базовый", "Глубокий"]
|
||||
Text(labels[i])
|
||||
.font(.system(size: 8))
|
||||
.foregroundColor(Color.white.opacity(0.25))
|
||||
.position(x: 35, y: y)
|
||||
}
|
||||
|
||||
// Filled step areas
|
||||
ForEach(segments) { seg in
|
||||
let x1 = w * CGFloat(seg.start.timeIntervalSince(timeStart) / totalSpan)
|
||||
let x2 = w * CGFloat(seg.end.timeIntervalSince(timeStart) / totalSpan)
|
||||
let segW = max(x2 - x1, 1)
|
||||
let y = yLevel(seg.phase) * chartH
|
||||
|
||||
// Fill from phase level to bottom
|
||||
Rectangle()
|
||||
.fill(seg.phase.color.opacity(0.15))
|
||||
.frame(width: segW, height: chartH - y)
|
||||
.position(x: x1 + segW / 2, y: y + (chartH - y) / 2)
|
||||
|
||||
// Top edge highlight
|
||||
Rectangle()
|
||||
.fill(seg.phase.color)
|
||||
.frame(width: segW, height: 3)
|
||||
.shadow(color: seg.phase.color.opacity(0.6), radius: 4, y: 0)
|
||||
.position(x: x1 + segW / 2, y: y)
|
||||
}
|
||||
|
||||
// Step line connecting phases
|
||||
Path { path in
|
||||
for (i, seg) in segments.enumerated() {
|
||||
let x = w * CGFloat(seg.start.timeIntervalSince(timeStart) / totalSpan)
|
||||
let y = yLevel(seg.phase) * chartH
|
||||
let xEnd = w * CGFloat(seg.end.timeIntervalSince(timeStart) / totalSpan)
|
||||
|
||||
if i == 0 { path.move(to: CGPoint(x: x, y: y)) }
|
||||
else { path.addLine(to: CGPoint(x: x, y: y)) }
|
||||
path.addLine(to: CGPoint(x: xEnd, y: y))
|
||||
}
|
||||
}
|
||||
.stroke(Color.white.opacity(0.5), style: StrokeStyle(lineWidth: 1.5))
|
||||
|
||||
// Time labels at bottom
|
||||
let hours = timeLabels()
|
||||
ForEach(hours, id: \.1) { (date, label) in
|
||||
let x = w * CGFloat(date.timeIntervalSince(timeStart) / totalSpan)
|
||||
Text(label)
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(Theme.textSecondary)
|
||||
.position(x: x, y: chartH + 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func timeLabels() -> [(Date, String)] {
|
||||
let cal = Calendar.current
|
||||
let f = DateFormatter(); f.dateFormat = "HH:mm"
|
||||
var labels: [(Date, String)] = []
|
||||
var date = cal.date(bySetting: .minute, value: 0, of: timeStart) ?? timeStart
|
||||
if date < timeStart { date = cal.date(byAdding: .hour, value: 1, to: date) ?? date }
|
||||
while date < timeEnd {
|
||||
labels.append((date, f.string(from: date)))
|
||||
date = cal.date(byAdding: .hour, value: 1, to: date) ?? timeEnd
|
||||
}
|
||||
// Keep max 6 labels to avoid overlap
|
||||
if labels.count > 6 {
|
||||
let step = labels.count / 5
|
||||
labels = stride(from: 0, to: labels.count, by: step).map { labels[$0] }
|
||||
}
|
||||
return labels
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,9 @@ struct WeeklyChartCard: View {
|
||||
|
||||
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)
|
||||
|
||||
GradientIcon(icon: "chart.xyaxis.line", colors: [Theme.purple, Theme.teal])
|
||||
Text("За неделю").font(.headline.weight(.semibold)).foregroundColor(.white)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
@@ -31,19 +26,16 @@ struct WeeklyChartCard: View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(ChartType.allCases, id: \.self) { type in
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
selectedChart = type
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
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)
|
||||
.foregroundColor(selectedChart == type ? .white : Theme.textSecondary)
|
||||
.padding(.horizontal, 16).padding(.vertical, 8)
|
||||
.background(
|
||||
selectedChart == type
|
||||
? Color(hex: "7c3aed").opacity(0.5)
|
||||
? chartColor.opacity(0.3)
|
||||
: Color.clear
|
||||
)
|
||||
.cornerRadius(10)
|
||||
@@ -51,34 +43,23 @@ struct WeeklyChartCard: View {
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.background(Color(hex: "1a1a3e"))
|
||||
.background(Color.white.opacity(0.06))
|
||||
.cornerRadius(12)
|
||||
|
||||
// Chart
|
||||
BarChartView(
|
||||
// Line Chart
|
||||
LineChartView(
|
||||
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)
|
||||
.glassCard(cornerRadius: 20)
|
||||
.padding(.horizontal)
|
||||
.onAppear {
|
||||
withAnimation(.easeOut(duration: 0.8).delay(0.3)) {
|
||||
appeared = true
|
||||
}
|
||||
withAnimation(.easeOut(duration: 0.8).delay(0.3)) { appeared = true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,17 +77,9 @@ struct WeeklyChartCard: View {
|
||||
|
||||
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
|
||||
case .sleep: return Theme.purple
|
||||
case .hrv: return Theme.teal
|
||||
case .steps: return Theme.orange
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,68 +92,124 @@ struct WeeklyChartCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bar Chart
|
||||
// MARK: - Line Chart
|
||||
|
||||
struct BarChartView: View {
|
||||
struct LineChartView: View {
|
||||
let values: [(date: String, value: Double)]
|
||||
let color: Color
|
||||
let maxValue: Double
|
||||
let unit: String
|
||||
let appeared: Bool
|
||||
|
||||
private var maxVal: Double {
|
||||
let m = values.map(\.value).max() ?? 1
|
||||
return m > 0 ? m * 1.15 : 1
|
||||
}
|
||||
|
||||
private var minVal: Double {
|
||||
let m = values.map(\.value).min() ?? 0
|
||||
return max(m * 0.85, 0)
|
||||
}
|
||||
|
||||
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
|
||||
let w = geo.size.width
|
||||
let h = geo.size.height - 24 // space for labels
|
||||
let count = max(values.count - 1, 1)
|
||||
|
||||
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"))
|
||||
ZStack(alignment: .topLeading) {
|
||||
// Grid lines
|
||||
ForEach(0..<4, id: \.self) { i in
|
||||
let y = h * CGFloat(i) / 3.0
|
||||
Path { path in
|
||||
path.move(to: CGPoint(x: 0, y: y))
|
||||
path.addLine(to: CGPoint(x: w, y: y))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.stroke(Color.white.opacity(0.04), lineWidth: 1)
|
||||
}
|
||||
|
||||
if values.count >= 2 {
|
||||
// Gradient fill under line
|
||||
Path { path in
|
||||
for (i, val) in values.enumerated() {
|
||||
let x = w * CGFloat(i) / CGFloat(count)
|
||||
let y = h * (1 - CGFloat((val.value - minVal) / max(maxVal - minVal, 1)))
|
||||
if i == 0 { path.move(to: CGPoint(x: x, y: y)) }
|
||||
else { path.addLine(to: CGPoint(x: x, y: y)) }
|
||||
}
|
||||
path.addLine(to: CGPoint(x: w, y: h))
|
||||
path.addLine(to: CGPoint(x: 0, y: h))
|
||||
path.closeSubpath()
|
||||
}
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [color.opacity(appeared ? 0.25 : 0), color.opacity(0)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.animation(.easeOut(duration: 1), value: appeared)
|
||||
|
||||
// Line
|
||||
Path { path in
|
||||
for (i, val) in values.enumerated() {
|
||||
let x = w * CGFloat(i) / CGFloat(count)
|
||||
let y = h * (1 - CGFloat((val.value - minVal) / max(maxVal - minVal, 1)))
|
||||
if i == 0 { path.move(to: CGPoint(x: x, y: y)) }
|
||||
else { path.addLine(to: CGPoint(x: x, y: y)) }
|
||||
}
|
||||
}
|
||||
.trim(from: 0, to: appeared ? 1 : 0)
|
||||
.stroke(
|
||||
LinearGradient(colors: [color, color.opacity(0.6)], startPoint: .leading, endPoint: .trailing),
|
||||
style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)
|
||||
)
|
||||
.shadow(color: color.opacity(0.5), radius: 6, y: 2)
|
||||
.animation(.easeOut(duration: 1), value: appeared)
|
||||
|
||||
// Dots
|
||||
ForEach(Array(values.enumerated()), id: \.offset) { i, val in
|
||||
let x = w * CGFloat(i) / CGFloat(count)
|
||||
let y = h * (1 - CGFloat((val.value - minVal) / max(maxVal - minVal, 1)))
|
||||
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 6, height: 6)
|
||||
.shadow(color: color.opacity(0.6), radius: 4)
|
||||
.position(x: x, y: y)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.animation(.easeOut(duration: 0.4).delay(Double(i) * 0.08), value: appeared)
|
||||
}
|
||||
}
|
||||
|
||||
// Date labels at bottom
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(values.enumerated()), id: \.offset) { _, val in
|
||||
Text(val.date)
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(Theme.textSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.offset(y: h + 6)
|
||||
|
||||
// Value labels on right
|
||||
VStack {
|
||||
Text(formatValue(maxVal))
|
||||
Spacer()
|
||||
Text(formatValue((maxVal + minVal) / 2))
|
||||
Spacer()
|
||||
Text(formatValue(minVal))
|
||||
}
|
||||
.font(.system(size: 8))
|
||||
.foregroundColor(Color.white.opacity(0.2))
|
||||
.frame(height: h)
|
||||
.offset(x: w - 28)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if value >= 1000 { return String(format: "%.0fк", value / 1000) }
|
||||
if value == floor(value) { return "\(Int(value))\(unit)" }
|
||||
return String(format: "%.1f", value)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user