diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index 3edf34c..beaf799 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -11,7 +11,10 @@ import Charts struct HeartChartDataPoint: Identifiable { var id = UUID() let date: Date - let value: Double + let min: Double + let max: Double + let average: Double + let values: [Double] } struct HeartChartView: View { @@ -22,21 +25,89 @@ struct HeartChartView: View { @AppStorage("maxHeartRange") private var maxHeartRange = 200 @State private var points = [HeartChartDataPoint]() + @State private var dayOffset: Int = 0 + @State private var displayedDate: Date = Date() + @State private var displayedMin: Int = 0 + @State private var displayedMax: Int = 0 + + var windowStart: Date { + Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: dayOffset, to: Date())!) + } + var windowEnd: Date { + Date(timeInterval: 86400, since: windowStart) + } + var windowPoints: [HeartChartDataPoint] { + points.filter { $0.date >= windowStart && $0.date <= windowEnd } + } func heartPoints() -> [HeartChartDataPoint] { - return ChartManager.shared.heartPoints().map { HeartChartDataPoint(date: $0.timestamp ?? Date(), value: $0.value) } + let raw = ChartManager.shared.heartPoints() + + let grouped = Dictionary(grouping: raw) { sample -> Date in + let comps = Calendar.current.dateComponents([.year, .month, .day, .hour], from: sample.timestamp ?? Date()) + return Calendar.current.date(from: comps) ?? Date() + } + + return grouped.map { (bucket, samples) in + let values = samples.map { $0.value } + return HeartChartDataPoint( + date: bucket, + min: values.min() ?? 0, + max: values.max() ?? 0, + average: values.reduce(0, +) / Double(values.count), + values: values + ) + }.sorted { $0.date < $1.date } } + var earliestDate: Date { - return points.compactMap({ $0.date }).min() ?? Date() + points.map({ $0.date }).min() ?? Date() } var latestDate: Date { - return points.compactMap({ $0.date }).max() ?? Date() + points.map({ $0.date }).max() ?? Date() } - var max: Int { - return Int(points.compactMap({ $0.value }).max() ?? 0) + + let heartColor = Color(red: 0.996, green: 0.212, blue: 0.369) + let darkHeartColor = Color(red: 0.369, green: 0.090, blue: 0.145) + + func isSingleReading(_ point: HeartChartDataPoint) -> Bool { + point.min == point.max + } + + @ChartContentBuilder + func chartContent(for point: HeartChartDataPoint) -> some ChartContent { + if isSingleReading(point) { + PointMark( + x: .value("Time", point.date), + y: .value("BPM", point.min) + ) + .foregroundStyle(heartColor) + .symbolSize(40) + .symbol(.circle) + } else { + RectangleMark( + x: .value("Time", point.date), + yStart: .value("Min", point.min), + yEnd: .value("Max", point.max), + width: 7 + ) + .foregroundStyle(darkHeartColor) + .clipShape(Capsule()) + + PointMark( + x: .value("Time", point.date), + y: .value("BPM", point.average) + ) + .foregroundStyle(heartColor) + .symbolSize(CGSize(width: 7, height: 7)) + .symbol(.circle) + } } - var min: Int { - return Int(points.compactMap({ $0.value }).min() ?? 0) + + func updateDisplayed() { + displayedDate = windowStart + displayedMin = Int(windowPoints.map({ $0.min }).min() ?? 0) + displayedMax = Int(windowPoints.map({ $0.max }).max() ?? 0) } var body: some View { @@ -46,30 +117,67 @@ struct HeartChartView: View { EmptyChartView(.heart) } else { Section { - Chart(points) { point in - PointMark( - x: .value("Time", point.date), - y: .value("BPM", point.value) - ) + VStack(spacing: 0) { + HStack { + Button { + dayOffset -= 1 + } label: { + Image(systemName: "chevron.left") + } + .disabled(Calendar.current.isDate(windowStart, inSameDayAs: earliestDate)) + + Spacer() + + Text(displayedDate.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day().year())) + .foregroundColor(.primary) + + Spacer() + + Button { + dayOffset += 1 + } label: { + Image(systemName: "chevron.right") + } + .disabled(dayOffset >= 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(.secondarySystemGroupedBackground)) .clipShape(Capsule()) - .foregroundStyle(Color.red) + .padding(.bottom, 8) + + Chart { + ForEach(windowPoints) { point in + chartContent(for: point) + } + } + .frame(height: 280) + .padding(.horizontal, 8) + .chartYScale(domain: (displayedMin - 20)...(displayedMax + 20)) + .chartXScale(domain: windowStart...windowEnd) + .chartXAxis { + AxisMarks(values: .stride(by: .hour, count: 6)) { value in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [4])) + AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .omitted))) + } + } + .chartYAxis { + AxisMarks(position: .trailing) { value in + AxisGridLine() + AxisValueLabel() + } + } } - .frame(height: 280) - .chartYScale(domain: minHeartRange...maxHeartRange) + .buttonStyle(.plain) } header: { VStack(alignment: .leading) { - Text(points.count > 1 ? "Range" : "No Data") - Text({ - if max == 0 || min == 0 { - return "0 " - } else { - return "\(min)-\(max) " - } - }()) - .font(.system(.title, design: .rounded)) - .foregroundColor(.primary) + Text("Range") + .font(.caption) + .foregroundColor(.secondary) + Text(displayedMax == 0 || displayedMin == 0 ? "0 " : "\(displayedMin)–\(displayedMax) ") + .font(.system(.title, design: .rounded)) + .foregroundColor(.primary) + Text("BPM") - Text("\(earliestDate.formatted(.dateTime.month(.abbreviated).day()))-\(latestDate.formatted(.dateTime.day()))") } .fontWeight(.semibold) } @@ -79,16 +187,26 @@ struct HeartChartView: View { .listRowBackground(Color.clear) if points.count >= 3 { Section { - Text("Today your heart rate reached a high of \(max), and dropped to a low of \(min) BPM.") - // Text("Is a heart point in an exercise in the last day: \(ExerciseViewModel.shared.isDateDuringExercise(Date()))") + Text("Today your heart rate reached a high of \(displayedMax), and dropped to a low of \(displayedMin) BPM.") } } } .onAppear { points = heartPoints() + updateDisplayed() + } + .onChange(of: dayOffset) { _ in + updateDisplayed() } .onChange(of: bleManager.heartRate) { _ in points = heartPoints() + updateDisplayed() } } } + +#Preview { + List { + HeartChartView() + } +} diff --git a/InfiniLink/Utils/ChartManager.swift b/InfiniLink/Utils/ChartManager.swift index e3bf5cd..9205acd 100644 --- a/InfiniLink/Utils/ChartManager.swift +++ b/InfiniLink/Utils/ChartManager.swift @@ -108,7 +108,7 @@ class ChartManager: ObservableObject { func heartPoints(predicate: NSPredicate? = nil) -> [HeartDataPoint] { let fetchRequest: NSFetchRequest = HeartDataPoint.fetchRequest() - fetchRequest.predicate = predicate ?? dayPredicate + fetchRequest.predicate = predicate ?? weekPredicate do { return try persistenceController.container.viewContext.fetch(fetchRequest)