Files
pulse-mobile/PulseHealth/Views/WeeklyChartView.swift
Cosmo 1eafeec5fe 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, анимации появления
2026-03-25 11:13:20 +00:00

187 lines
6.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}