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) { HStack { GradientIcon(icon: "chart.xyaxis.line", colors: [Theme.purple, Theme.teal]) 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 : Theme.textSecondary) .padding(.horizontal, 16).padding(.vertical, 8) .background( selectedChart == type ? chartColor.opacity(0.3) : Color.clear ) .cornerRadius(10) } } } .padding(4) .background(Color.white.opacity(0.06)) .cornerRadius(12) // Line Chart LineChartView( values: chartValues, color: chartColor, unit: chartUnit, appeared: appeared ) .frame(height: 160) } .padding(20) .glassCard(cornerRadius: 20) .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 Theme.purple case .hrv: return Theme.teal case .steps: return Theme.orange } } private var chartUnit: String { switch selectedChart { case .sleep: return "ч" case .hrv: return "мс" case .steps: return "" } } } // MARK: - Line Chart struct LineChartView: View { let values: [(date: String, value: Double)] let color: Color 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 w = geo.size.width let h = geo.size.height - 24 // space for labels let count = max(values.count - 1, 1) 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)) } .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: "%.0fк", value / 1000) } if value == floor(value) { return "\(Int(value))\(unit)" } return String(format: "%.1f", value) } }