diff --git a/InfiniLink/Core/Components/Charts/EmptyChartView.swift b/InfiniLink/Core/Components/Charts/EmptyChartView.swift index dfe640e..c06147d 100644 --- a/InfiniLink/Core/Components/Charts/EmptyChartView.swift +++ b/InfiniLink/Core/Components/Charts/EmptyChartView.swift @@ -20,27 +20,9 @@ struct Line: Shape { } struct EmptyChartView: View { - @AppStorage("heartRateChartDataSelection") private var dataSelection = 0 - let chartType: ChartType private let backgroundColor = Color.primary.opacity(0.35) - private var detailString: String { - var base = "There isn't any \(chartType.rawValue) data to show " - - switch dataSelection { - case 1: - base += "for today" - case 2: - base += "for the week" - case 3: - base += "for the month" - default: - base += "for the current hour" - } - - return base - } init(_ chartType: ChartType) { self.chartType = chartType @@ -64,7 +46,7 @@ struct EmptyChartView: View { VStack { Text("Nothing to see here") .font(.title2.weight(.bold)) - Text(detailString) + Text("There isn't any \(chartType.rawValue) data to show") .foregroundStyle(.gray) } } diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index 9ffb30f..a375f0a 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -8,92 +8,334 @@ 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 { @ObservedObject var bleManager = BLEManager.shared - @ObservedObject private var notificationSettings = NotificationSettingsManager.shared - - @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() + @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 + + var visiblePoints: [HeartChartDataPoint] { + let visibleEnd = Date(timeInterval: 86400, since: scrollPositionDate) + return points.filter { $0.date >= scrollPositionDate && $0.date <= visibleEnd } + } + var earliestDate: Date { + points.map({ $0.date }).min() ?? Date() + } + var latestDate: 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: "") + } + private var bpmText: String { + if let selectedViewHour { + return isSingleReading(selectedViewHour) + ? "\(Int(selectedViewHour.min))" + : "\(Int(selectedViewHour.min))–\(Int(selectedViewHour.max))" + } + + return displayedMax == 0 || displayedMin == 0 ? "0" : "\(displayedMin)–\(displayedMax)" + } - private var heartRange: ClosedRange { - let heart = notificationSettings.settings.heartSettings - return heart.minRange...Swift.max(heart.minRange + 1, heart.maxRange) + private var detailText: String { + 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 + + let style = Date.FormatStyle() + .hour(.defaultDigits(amPM: .abbreviated)) + + return + "\(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)" : "")" + } + + let rounded = Date(timeIntervalSinceReferenceDate: (scrollPositionDate.timeIntervalSinceReferenceDate / 3600).rounded() * 3600) + + let end = Date(timeInterval: 86400, since: rounded) + let isFullDay = cal.component(.hour, from: rounded) == 0 + + return 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()))" + } + + 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.startOfDay(for: cal.date(byAdding: .day, value: -1, to: date)!) + let end = cal.startOfDay(for: cal.date(byAdding: .day, value: 2, 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) - func load() async { - points = await ChartManager.shared.heartRateSamples().map { HeartChartDataPoint(date: $0.date, value: $0.value) } + if !raw.isEmpty { + points = process(raw) + } + loadedRange = DateInterval(start: start, end: end) } - var earliestDate: Date { - return points.compactMap({ $0.date }).min() ?? Date() + + 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 } } - var latestDate: Date { - return points.compactMap({ $0.date }).max() ?? Date() + + func isSingleReading(_ point: HeartChartDataPoint) -> Bool { + point.min == point.max } - var max: Int { - return Int(points.compactMap({ $0.value }).max() ?? 0) + + @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) + } + + 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: heartRange) - } 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") - if max == 0 || min == 0 { - Text(0, format: .number) - .font(.system(.title, design: .rounded)) - .foregroundColor(.primary) - + Text(" BPM") - } else { - (Text(min, format: .number) + Text("-") + Text(max, format: .number)) + Text("Range") + .font(.caption) + .foregroundColor(.secondary) + Group { + Text("\(bpmText) ") .font(.system(.title, design: .rounded)) .foregroundColor(.primary) - + Text(" BPM") + + Text("BPM") } - Text("\(earliestDate.formatted(.dateTime.month(.abbreviated).day()))-\(latestDate.formatted(.dateTime.day()))") + .contentTransition(.numericText()) + Text(detailText) + .foregroundColor(.secondary) + .font(.subheadline) } .fontWeight(.semibold) + if #unavailable(iOS 17), selectedViewHour == nil { + Spacer() + scrollButton(-1, disabled: false) + scrollButton(1, disabled: cal.startOfDay(for: scrollPositionDate) >= cal.startOfDay(for: Date())) + } } - .listRowInsets(EdgeInsets(top: 18, leading: 0, bottom: 0, trailing: 0)) } + .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()))") + } + .listRowBackground(Color.clear) + .onAppear { + fetchPoints(around: Date()) + updateYScale() + } + .onChange(of: scrollPositionDate) { newValue in + let maxScroll = cal.startOfDay(for: Date()) + if newValue > maxScroll { // Don't allow overscroll into the next day + scrollPositionDate = maxScroll + return + } + + guard let loadedRange else { return } + + if newValue < loadedRange.start.addingTimeInterval(visibleDomain) || + newValue > loadedRange.end.addingTimeInterval(-visibleDomain) { + fetchPoints(around: newValue) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + withAnimation { + updateYScale() } } } - .task { - await load() - } .onChange(of: bleManager.heartRate) { _ in - Task { await load() } + 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/Developer/DeveloperView.swift b/InfiniLink/Core/Developer/DeveloperView.swift index f633b27..ce4d924 100644 --- a/InfiniLink/Core/Developer/DeveloperView.swift +++ b/InfiniLink/Core/Developer/DeveloperView.swift @@ -15,12 +15,39 @@ struct DeveloperView: View { @AppStorage("forceAncs") var forceAncs = true @AppStorage("dfuPacketReceiptNotification") var packetReceiptNotification = 0 + @State private var heartDay = "" + @State private var generatedDayOffset = 0 + private let bleWriteManager = BLEWriteManager() private let musicController = MusicController.shared private let healthKitManager = HealthKitManager.shared - private let deviceManager = DeviceManager.shared - private let chartManager = ChartManager.shared - private let stepCountManager = StepCountManager.shared + private let persistenceController = PersistenceController.shared + + func generateRandomHeartPoints(dayOffset: Int) { + let context = persistenceController.container.viewContext + + let calendar = Calendar.current + let targetDay = calendar.date(byAdding: .day, value: dayOffset, to: Date())! + + let startOfDay = calendar.startOfDay(for: targetDay) + let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! + + for _ in 0..<150 { + let randomTime = TimeInterval.random(in: 0.. 0 else { return 0.0 } - return Double(ints.reduce(0, +)) / Double(ints.count) - }()) - ) - DetailHeaderSubItemView( - title: "Max", - value: heartRate(for: values.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 18aa5ff..e19600a 100644 --- a/InfiniLink/Core/Settings/HeartSettingsView.swift +++ b/InfiniLink/Core/Settings/HeartSettingsView.swift @@ -16,6 +16,7 @@ private struct HeartExportRow: Sendable { struct HeartSettingsView: View { @AppStorage("backgroundHRMMeasurements") var backgroundHRMMeasurements = false @AppStorage("filterHeartRateData") var filterHeartRateData = true + @AppStorage("heartPointMarkMode") var heartPointMarkMode = "average" @State private var exportDate = Date() @State private var isExporting = false @@ -95,6 +96,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) + } Section { DatePicker("Day", selection: $exportDate, in: ...Date(), displayedComponents: .date) if isExporting { diff --git a/InfiniLink/Localizable.xcstrings b/InfiniLink/Localizable.xcstrings index e70a446..52f105d 100644 --- a/InfiniLink/Localizable.xcstrings +++ b/InfiniLink/Localizable.xcstrings @@ -51,6 +51,9 @@ }, "%@" : { + }, + "%@ " : { + }, "%@ - %@" : { "localizations" : { @@ -94,16 +97,6 @@ } } }, - "%@-%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@-%2$@" - } - } - } - }, "%@:%@:%@" : { "localizations" : { "en" : { @@ -174,6 +167,28 @@ }, "%lld steps" : { + }, + "%lld time%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld time%2$@" + } + } + } + }, + "%lld-day" : { + + }, + "%lld%%" : { + + }, + "0 " : { + + }, + "0%" : { + }, "1 device discovered" : { @@ -195,6 +210,9 @@ }, "Add 2" : { + }, + "Add a Day" : { + }, "Age" : { @@ -268,6 +286,9 @@ } } } + }, + "avg" : { + }, "Back" : { @@ -310,11 +331,14 @@ }, "Checking for updates..." : { + }, + "Choose how the point mark on the heart rate chart is calculated." : { + }, "Clear All Exercises" : { }, - "Clear Disconnect Pins" : { + "Clear HRM Data" : { }, "Clear Step Data" : { @@ -393,6 +417,9 @@ }, "Day" : { + }, + "Days" : { + }, "Debug Logs" : { @@ -851,6 +878,9 @@ } } } + }, + "Max" : { + }, "Maximum" : { "localizations" : { @@ -861,9 +891,18 @@ } } } + }, + "mdn" : { + + }, + "Median" : { + }, "Metric" : { + }, + "Min" : { + }, "Minimum" : { @@ -901,9 +940,6 @@ }, "No" : { - }, - "No Data" : { - }, "No Logs" : { @@ -974,6 +1010,9 @@ }, "Pinned" : { + }, + "Point Mark" : { + }, "Poor" : { @@ -1040,6 +1079,9 @@ }, "Selected" : { + }, + "Selected Hour" : { + }, "Send" : { @@ -1175,6 +1217,9 @@ }, "Test HealthKit" : { + }, + "Test HRM" : { + }, "Test Music" : { @@ -1211,6 +1256,9 @@ }, "There aren't any available releases." : { + }, + "There isn't any %@ data to show" : { + }, "There wasn't any heart rate data recorded for this exercise." : { @@ -1251,6 +1299,9 @@ }, "To customize battery notifications, you need to allow notifications in notification settings." : { + }, + "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" : { diff --git a/InfiniLink/Utils/ChartManager.swift b/InfiniLink/Utils/ChartManager.swift index 47afbd5..819709a 100644 --- a/InfiniLink/Utils/ChartManager.swift +++ b/InfiniLink/Utils/ChartManager.swift @@ -15,9 +15,6 @@ struct HeartRateSample { } class ChartManager: ObservableObject { - @AppStorage("heartRateChartDataSelection") var heartRateChartDataSelection = 0 - @AppStorage("stepChartDataSelection") var stepChartDataSelection = 0 - static let shared = ChartManager() let persistenceController = PersistenceController.shared @@ -236,19 +233,17 @@ class ChartManager: ObservableObject { } } - func deleteAllDisconnectMapPoints(all: Bool = true) { + func clearHrmData() { let context = persistenceController.container.newBackgroundContext() context.perform { - let fetchRequest: NSFetchRequest = DisconnectMapPoint.fetchRequest() - fetchRequest.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: false)] - fetchRequest.fetchOffset = all ? 0 : 3 + let fetchRequest: NSFetchRequest = HeartDataPoint.fetchRequest() let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { try context.execute(batchDeleteRequest) try context.save() } catch { - log("Failed to delete disconnect pins: \(error)", caller: "ChartManager") + log("Failed to delete hrm day: \(error)", caller: "ChartManager") } } } diff --git a/README.md b/README.md index 6b8b74c..bf3fe11 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ - Download and install InfiniTime firmware updates from GitHub releases and local files ### 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: