feat: полноценное Pulse приложение с TabBar

- Auth: переключено на Pulse API (api.digital-home.site) вместо health
- TabBar: Главная, Задачи, Привычки, Здоровье, Финансы
- Models: TaskModels, HabitModels, FinanceModels, обновлённые AuthModels
- Services: APIService (Pulse API), HealthAPIService (health отдельно)
- Dashboard: обзор дня с задачами, привычками, readiness, балансом
- Tasks: список, фильтр, создание, выполнение, удаление
- Habits: список с прогресс-баром, отметка выполнения, стрики
- Health: бывший DashboardView, HealthKit sync через health API key
- Finance: баланс, список транзакций, добавление расхода/дохода
- Health данные через x-api-key вместо JWT токена health сервиса
This commit is contained in:
Cosmo
2026-03-25 11:49:52 +00:00
parent cf0e535639
commit c015824b36
23 changed files with 1090 additions and 202 deletions

View File

@@ -0,0 +1,226 @@
import SwiftUI
struct HealthView: View {
@EnvironmentObject var authManager: AuthManager
@StateObject private var healthKit = HealthKitService()
@State private var readiness: ReadinessResponse?
@State private var latest: LatestHealthResponse?
@State private var heatmapData: [HeatmapEntry] = []
@State private var isLoading = true
// Toast state
@State private var showToast = false
@State private var toastMessage = ""
@State private var toastSuccess = true
var greeting: String {
let hour = Calendar.current.component(.hour, from: Date())
switch hour {
case 5..<12: return "Доброе утро"
case 12..<17: return "Добрый день"
case 17..<22: return "Добрый вечер"
default: return "Доброй ночи"
}
}
var dateString: String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "ru_RU")
formatter.dateFormat = "d MMMM, EEEE"
return formatter.string(from: Date())
}
var body: some View {
ZStack {
Color(hex: "0a0a1a")
.ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
// MARK: - Header
headerView
.padding(.top, 8)
if isLoading {
loadingView
} else {
// MARK: - Readiness
if let r = readiness {
ReadinessCardView(readiness: r)
}
// MARK: - Metrics Grid
metricsGrid
// MARK: - Weekly Chart
if !heatmapData.isEmpty {
WeeklyChartCard(heatmapData: heatmapData)
}
// MARK: - Insights
InsightsCard(readiness: readiness, latest: latest)
Spacer(minLength: 30)
}
}
}
.refreshable {
await loadData()
}
}
.toast(isShowing: $showToast, message: toastMessage, isSuccess: toastSuccess)
.task { await loadData() }
}
// MARK: - Header
private var headerView: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Здоровье 🫀")
.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) {
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)
}
}
.padding(.horizontal)
}
// MARK: - Load Data
func loadData() async {
isLoading = true
let apiKey = authManager.healthApiKey
async let r = HealthAPIService.shared.getReadiness(apiKey: apiKey)
async let l = HealthAPIService.shared.getLatest(apiKey: apiKey)
async let h = HealthAPIService.shared.getHeatmap(apiKey: apiKey, days: 7)
readiness = try? await r
latest = try? await l
heatmapData = (try? await h) ?? []
isLoading = false
}
// MARK: - Sync HealthKit
func syncHealthKit() async {
guard healthKit.isAvailable else {
showToastMessage("HealthKit недоступен на этом устройстве", success: false)
return
}
guard !authManager.healthApiKey.isEmpty else {
showToastMessage("Health API ключ не найден", success: false)
return
}
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
do {
try await healthKit.syncToServer(apiKey: authManager.healthApiKey)
UINotificationFeedbackGenerator().notificationOccurred(.success)
showToastMessage("Данные синхронизированы ✓", success: true)
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

@@ -0,0 +1,369 @@
import SwiftUI
// MARK: - Gradient Icon
struct GradientIcon: View {
let icon: String
let colors: [Color]
var size: CGFloat = 36
var body: some View {
ZStack {
Circle()
.fill(
LinearGradient(
colors: colors.map { $0.opacity(0.2) },
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: size, height: size)
Image(systemName: icon)
.font(.system(size: size * 0.4))
.foregroundStyle(
LinearGradient(
colors: colors,
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
}
}
// MARK: - Base Metric Card
struct MetricCardView: View {
let icon: String
let title: String
let value: String
let subtitle: String
let color: Color
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
}
}
}
}

View File

@@ -0,0 +1,152 @@
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)
}
}
}

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