diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index 3edf34c..80316e6 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -8,10 +8,17 @@ import SwiftUI import Charts -struct HeartChartDataPoint: Identifiable { +fileprivate let heartColor = Color.pink +fileprivate let darkHeartColor = Color(red: 0.369, green: 0.090, blue: 0.145) // dark pink + +struct HeartChartDataPoint: Identifiable, Equatable { var id = UUID() let date: Date - let value: Double + let min: Double + let max: Double + let average: Double + let median: Double + let values: [Double] } struct HeartChartView: View { @@ -20,75 +27,285 @@ struct HeartChartView: View { @AppStorage("heartRateChartDataSelection") private var dataSelection = 0 @AppStorage("minHeartRange") private var minHeartRange = 40 @AppStorage("maxHeartRange") private var maxHeartRange = 200 + @AppStorage("heartPointMarkMode") private var heartPointMarkMode = "average" @State private var points = [HeartChartDataPoint]() + @State private var loadedRange: DateInterval? + @State private var scrollPositionDate: Date = Date() + @State private var rawSelectedHour: Date? = nil + @State private var displayedMin: Int = 40 + @State private var displayedMax: Int = 220 + + private let cal = Calendar.current + private let visibleDomain: TimeInterval = 86400 - func heartPoints() -> [HeartChartDataPoint] { - return ChartManager.shared.heartPoints().map { HeartChartDataPoint(date: $0.timestamp ?? Date(), value: $0.value) } + var visiblePoints: [HeartChartDataPoint] { + let visibleEnd = Date(timeInterval: 86400, since: scrollPositionDate) + return points.filter { $0.date >= scrollPositionDate && $0.date <= visibleEnd } } 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 selectedViewHour: HeartChartDataPoint? { + guard let rawSelectedHour else { return nil } + return points.first { + cal.isDate(rawSelectedHour, equalTo: $0.date, toGranularity: .hour) + } + } + var pointMarkLabel: String { + heartPointMarkMode == "average" ? NSLocalizedString("avg", comment: "") : NSLocalizedString("mdn", comment: "") + } + func pointMarkValue(for point: HeartChartDataPoint) -> Double { + heartPointMarkMode == "average" ? point.average : point.median + } + + func updateYScale() { + displayedMin = Int(visiblePoints.map({ $0.min }).min() ?? 40) + displayedMax = Int(visiblePoints.map({ $0.max }).max() ?? 220) + } + + func fetchPoints(around date: Date) { + let start = cal.date(byAdding: .day, value: -1, to: date)! + let end = cal.date(byAdding: .day, value: 1, to: date)! + let predicate = NSPredicate( + format: "deviceId == %@ AND timestamp >= %@ AND timestamp < %@", + bleManager.pairedDeviceID!, + start as NSDate, + end as NSDate + ) + + let raw = ChartManager.shared.heartPoints(predicate: predicate) + if raw.isEmpty { return } + + points = process(raw) + loadedRange = DateInterval(start: start, end: end) + } + + func process(_ raw: [HeartDataPoint]) -> [HeartChartDataPoint] { + let grouped = Dictionary(grouping: raw) { sample -> Date in + let comps = cal.dateComponents([.year, .month, .day, .hour], from: sample.timestamp ?? Date()) + return cal.date(from: comps) ?? Date() + } + + return grouped + .map { bucket, samples in + let values = samples.map(\.value) + + return HeartChartDataPoint( + date: cal.date(byAdding: .minute, value: 30, to: bucket) ?? bucket, + min: values.min() ?? 0, + max: values.max() ?? 0, + average: values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count), + median: { + let sorted = values.sorted() + let mid = sorted.count / 2 + return sorted.count % 2 == 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid] + }(), + values: values + ) + } + .sorted { $0.date < $1.date } + } + + func isSingleReading(_ point: HeartChartDataPoint) -> Bool { + point.min == point.max + } + + @ChartContentBuilder + func chartContent(for point: HeartChartDataPoint, selected: HeartChartDataPoint?) -> some ChartContent { + BarMark( + x: .value("Time", point.date), + yStart: .value("Min", point.min), + yEnd: .value("Max", point.max), + width: 7 + ) + .foregroundStyle(darkHeartColor) + .cornerRadius(4) + .opacity(selected == nil || selected?.date == point.date ? 1 : 0.35) + + PointMark( + x: .value("Time", point.date), + y: .value("BPM", pointMarkValue(for: point)) + ) + .foregroundStyle(heartColor) + .symbolSize(CGSize(width: 7, height: 7)) + .symbol(.circle) + .opacity(selected == nil || selected?.date == point.date ? 1 : 0.1) } - var max: Int { - return Int(points.compactMap({ $0.value }).max() ?? 0) + + func scrollButton(_ dir: Int, disabled: Bool) -> some View { + Button { + scrollPositionDate = cal.date(byAdding: .day, value: dir, to: scrollPositionDate)! + } label: { + Image(systemName: dir == 1 ? "chevron.right" : "chevron.left") + .padding(12) + .foregroundStyle(Color.primary) + .fontWeight(.medium) + .background(Material.regular) + .clipShape(Circle()) + } + .disabled(disabled) + .opacity(disabled ? 0.5 : 1) } - var min: Int { - return Int(points.compactMap({ $0.value }).min() ?? 0) + + func chart() -> some View { + let xMin = cal.startOfDay(for: earliestDate) + let xMax = cal.startOfDay(for: latestDate) + 86400 + 3600 + let yMin = displayedMin - 20 + let yMax = displayedMax + 20 + + var chart: some View { + Chart { + if let selectedViewHour { + RuleMark(x: .value("Selected Hour", selectedViewHour.date, unit: .hour)) + .foregroundStyle(Color.gray) + } + ForEach(points) { point in + chartContent(for: point, selected: selectedViewHour) + } + } + .frame(minHeight: 280) + .padding(.horizontal, 8) + .chartYScale(domain: yMin...yMax) + .chartXScale(domain: xMin...xMax) + .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() + } + } + } + + return Group { + if #available(iOS 17, *) { + chart + .chartScrollableAxes(.horizontal) + .chartXVisibleDomain(length: visibleDomain) + .chartScrollPosition(x: $scrollPositionDate) + .chartScrollTargetBehavior( + .valueAligned( + matching: DateComponents(timeZone: .current, minute: 0, second: 0), + majorAlignment: .matching(DateComponents(timeZone: .current, hour: 0)) + ) + ) + .chartXSelection(value: $rawSelectedHour) + } else { + chart + .overlay( + GeometryReader { geo in + Color.clear + .contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance: 0) + .onChanged { value in + let adjustedWidth = geo.size.width - 48 + let normalizedXPosition = min(max(value.location.x - 8, 0), adjustedWidth) / adjustedWidth + rawSelectedHour = xMin.addingTimeInterval(normalizedXPosition * 86400) + } + .onEnded { _ in + rawSelectedHour = nil + } + ) + } + ) + } + } } var body: some View { Group { - Group { - if points.count <= 1 { - EmptyChartView(.heart) - } else { - Section { - Chart(points) { point in - PointMark( - x: .value("Time", point.date), - y: .value("BPM", point.value) - ) - .clipShape(Capsule()) - .foregroundStyle(Color.red) - } - .frame(height: 280) - .chartYScale(domain: minHeartRange...maxHeartRange) - } header: { + if points.flatMap({ $0.values }).count <= 1 { + EmptyChartView(.heart) + } else { + Section { + chart() + } header: { + HStack { 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("BPM") - Text("\(earliestDate.formatted(.dateTime.month(.abbreviated).day()))-\(latestDate.formatted(.dateTime.day()))") + Text("Range") + .font(.caption) + .foregroundColor(.secondary) + if let selectedViewHour { + let rangeFirstHour = cal.dateInterval(of: .hour, for: selectedViewHour.date)?.start ?? selectedViewHour.date + let rangeLastHour = cal.date(byAdding: .hour, value: 1, to: rangeFirstHour) ?? rangeFirstHour + + Text(isSingleReading(selectedViewHour) ? "\(Int(selectedViewHour.min)) " : "\(Int(selectedViewHour.min))–\(Int(selectedViewHour.max)) ") + .font(.system(.title, design: .rounded)) + .foregroundColor(.primary) + + Text("BPM") + + let style = Date.FormatStyle().hour(.defaultDigits(amPM: .abbreviated)) + Text("\(rangeFirstHour.formatted(.dateTime.month(.abbreviated).day())), \(rangeFirstHour.formatted(style))–\(rangeLastHour.formatted(style)) · \(selectedViewHour.values.count) \(selectedViewHour.values.count == 1 ? "reading" : "readings")\(selectedViewHour.values.count > 1 ? " · \(Int(pointMarkValue(for: selectedViewHour))) BPM \(pointMarkLabel)" : "")") + .foregroundColor(.secondary) + .font(.subheadline) + } else { + Text(displayedMax == 0 || displayedMin == 0 ? "0 " : "\(displayedMin)–\(displayedMax) ") + .font(.system(.title, design: .rounded)) + .foregroundColor(.primary) + + Text("BPM") + let rounded = Date(timeIntervalSinceReferenceDate: (scrollPositionDate.timeIntervalSinceReferenceDate / 3600).rounded() * 3600) + let end = Date(timeInterval: 86400, since: rounded) + let isFullDay = cal.component(.hour, from: rounded) == 0 + Text(isFullDay + ? rounded.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day().year()) + : "\(rounded.formatted(.dateTime.month(.abbreviated).day())), \(rounded.formatted(.dateTime.hour().minute())) – \(end.formatted(.dateTime.month(.abbreviated).day())), \(end.formatted(.dateTime.hour().minute()))") + .foregroundColor(.secondary) + .font(.subheadline) + } } .fontWeight(.semibold) + if #unavailable(iOS 17), selectedViewHour == nil { + Spacer() + scrollButton(-1, disabled: cal.startOfDay(for: scrollPositionDate) <= cal.startOfDay(for: earliestDate)) + scrollButton(1, disabled: cal.startOfDay(for: scrollPositionDate) >= cal.startOfDay(for: latestDate)) + } } - .listRowInsets(EdgeInsets(top: 18, leading: 0, bottom: 0, trailing: 0)) - } - } - .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()))") } + .listRowInsets(EdgeInsets(top: 18, leading: 0, bottom: 0, trailing: 0)) } } + .listRowBackground(Color.clear) .onAppear { - points = heartPoints() + fetchPoints(around: Date()) + scrollPositionDate = cal.startOfDay(for: latestDate) + updateYScale() + } + .onChange(of: scrollPositionDate) { newValue in + guard let loadedRange else { return } + + let threshold = visibleDomain / 2 + + if newValue.timeIntervalSince(loadedRange.start) <= threshold || + newValue.timeIntervalSince(loadedRange.end) >= threshold { + fetchPoints(around: newValue) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + if scrollPositionDate == newValue { + updateYScale() + } + } } .onChange(of: bleManager.heartRate) { _ in - points = heartPoints() + let previousLatest = latestDate + fetchPoints(around: Date()) + if !cal.isDate(latestDate, inSameDayAs: previousLatest) { + scrollPositionDate = cal.startOfDay(for: latestDate) + } + } + .onChange(of: selectedViewHour?.date) { newValue in + guard newValue != nil else { return } + UIImpactFeedbackGenerator(style: .light).impactOccurred() } } } diff --git a/InfiniLink/Core/HeartView.swift b/InfiniLink/Core/HeartView.swift index cc1f2ea..6521dff 100644 --- a/InfiniLink/Core/HeartView.swift +++ b/InfiniLink/Core/HeartView.swift @@ -45,24 +45,6 @@ struct HeartView: View { List { Section { DetailHeaderView(Header(title: String(format: "%.0f", heartPointValues.last ?? 0), subtitle: timestamp(for: chartManager.heartPoints().last), units: "BPM", icon: "heart.fill", accent: .red), width: geo.size.width, animate: (chartManager.heartPoints().last?.timestamp?.timeIntervalSinceNow ?? 60) < 60) { - HStack { - DetailHeaderSubItemView( - title: "Min", - value: heartRate(for: heartPointValues.min() ?? 0) - ) - DetailHeaderSubItemView( - title: "Avg", - value: heartRate(for: { - let ints = heartPointValues.compactMap { Int($0) } - guard ints.count > 0 else { return 0.0 } - return Double(ints.reduce(0, +)) / Double(ints.count) - }()) - ) - DetailHeaderSubItemView( - title: "Max", - value: heartRate(for: heartPointValues.max() ?? 0) - ) - } } } .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) diff --git a/InfiniLink/Core/Settings/HeartSettingsView.swift b/InfiniLink/Core/Settings/HeartSettingsView.swift index c8c3895..7f311ca 100644 --- a/InfiniLink/Core/Settings/HeartSettingsView.swift +++ b/InfiniLink/Core/Settings/HeartSettingsView.swift @@ -10,6 +10,7 @@ import SwiftUI struct HeartSettingsView: View { @AppStorage("backgroundHRMMeasurements") var backgroundHRMMeasurements = false @AppStorage("filterHeartRateData") var filterHeartRateData = true + @AppStorage("heartPointMarkMode") var heartPointMarkMode = "average" @FetchRequest(sortDescriptors: [SortDescriptor(\.timestamp)]) var heartPoints: FetchedResults @@ -60,6 +61,13 @@ struct HeartSettingsView: View { Section(footer: Text("Filter inconsistent data from your heart rate measurements.")) { Toggle("Filter Values", isOn: $filterHeartRateData) } + Section(footer: Text("Choose how the point mark on the heart rate chart is calculated.")) { + Picker("Point Mark", selection: $heartPointMarkMode) { + Text("Average").tag("average") + Text("Median").tag("median") + } + .pickerStyle(.menu) + } Button { exportCSV(generateCSV(from: Array(heartPoints))) } label: { diff --git a/InfiniLink/Localizable.xcstrings b/InfiniLink/Localizable.xcstrings index 986e748..4fc4183 100644 --- a/InfiniLink/Localizable.xcstrings +++ b/InfiniLink/Localizable.xcstrings @@ -97,12 +97,12 @@ } } }, - "%@-%@" : { + "%@, %@–%@ · %lld %@%@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$@-%2$@" + "value" : "%1$@, %2$@–%3$@ · %4$lld %5$@%6$@" } } } @@ -175,18 +175,18 @@ } } }, - "%lld-%lld " : { + "%lld-day" : { + + }, + "%lld–%lld " : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$lld-%2$lld " + "value" : "%1$lld–%2$lld " } } } - }, - "%lld-day" : { - }, "%lld%%" : { @@ -272,6 +272,9 @@ } } } + }, + "avg" : { + }, "Back" : { @@ -311,6 +314,9 @@ }, "Checking for updates..." : { + }, + "Choose how the point mark on the heart rate chart is calculated." : { + }, "Clear All Exercises" : { @@ -819,6 +825,9 @@ } } } + }, + "Max" : { + }, "Maximum" : { "localizations" : { @@ -829,9 +838,18 @@ } } } + }, + "mdn" : { + + }, + "Median" : { + }, "Metric" : { + }, + "Min" : { + }, "Minimum" : { @@ -869,9 +887,6 @@ }, "No" : { - }, - "No Data" : { - }, "No Logs" : { @@ -929,6 +944,9 @@ }, "Pinned" : { + }, + "Point Mark" : { + }, "Poor" : { @@ -980,6 +998,9 @@ }, "Search for an address..." : { + }, + "Selected Hour" : { + }, "Send" : { @@ -1196,16 +1217,6 @@ }, "To start a route, you need to enable \"Always Allow\" location permissions for InfiniLink in Settings." : { - }, - "Today your heart rate reached a high of %lld, and dropped to a low of %lld BPM." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Today your heart rate reached a high of %1$lld, and dropped to a low of %2$lld BPM." - } - } - } }, "Total" : { @@ -1443,4 +1454,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/README.md b/README.md index 2752581..f48b610 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,6 @@ - Download and install InfiniTime firmware updates from releases and GitHub Actions using the GitHub API (local file updates are supported) ### Partially implemented features: -- Apple Charts with date range selection - System-wide notifications—implemented in [#2217](https://github.com/InfiniTimeOrg/InfiniTime/pull/2217), but not available in the main branch yet. ### Currently non-functional features: