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 3edf34c..a375f0a 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -8,87 +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 - @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 - 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: "") + } + 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 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) } - var max: Int { - return Int(points.compactMap({ $0.value }).max() ?? 0) + + 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) + + if !raw.isEmpty { + 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) + } + + 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) + Group { + Text("\(bpmText) ") + .font(.system(.title, design: .rounded)) + .foregroundColor(.primary) + + Text("BPM") + } + .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)) - } - } - .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()) + 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() + } + } } .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/Developer/DeveloperView.swift b/InfiniLink/Core/Developer/DeveloperView.swift index 946bb07..8794ae7 100644 --- a/InfiniLink/Core/Developer/DeveloperView.swift +++ b/InfiniLink/Core/Developer/DeveloperView.swift @@ -14,12 +14,39 @@ struct DeveloperView: View { @AppStorage("includeTestSongName") var includeTestSongName = true @AppStorage("forceAncs") var forceAncs = false + @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: 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 063af91..594f428 100644 --- a/InfiniLink/Localizable.xcstrings +++ b/InfiniLink/Localizable.xcstrings @@ -46,6 +46,9 @@ }, "%@" : { + }, + "%@ " : { + }, "%@ - %@" : { "localizations" : { @@ -86,16 +89,6 @@ } } }, - "%@-%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@-%2$@" - } - } - } - }, "%@:%@:%@" : { "localizations" : { "en" : { @@ -164,16 +157,6 @@ } } }, - "%lld-%lld " : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$lld-%2$lld " - } - } - } - }, "%lld-day" : { }, @@ -206,6 +189,9 @@ }, "Add 2" : { + }, + "Add a Day" : { + }, "All Exercises" : { @@ -261,6 +247,9 @@ } } } + }, + "avg" : { + }, "Back" : { @@ -300,11 +289,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" : { @@ -377,6 +369,9 @@ }, "Date" : { + }, + "Days" : { + }, "Debug Logs" : { @@ -797,6 +792,9 @@ } } } + }, + "Max" : { + }, "Maximum" : { "localizations" : { @@ -807,9 +805,18 @@ } } } + }, + "mdn" : { + + }, + "Median" : { + }, "Metric" : { + }, + "Min" : { + }, "Minimum" : { @@ -847,9 +854,6 @@ }, "No" : { - }, - "No Data" : { - }, "No Logs" : { @@ -911,6 +915,9 @@ }, "Pinned" : { + }, + "Point Mark" : { + }, "Poor" : { @@ -965,6 +972,9 @@ }, "Selected" : { + }, + "Selected Hour" : { + }, "Send" : { @@ -1110,6 +1120,9 @@ }, "Test HealthKit" : { + }, + "Test HRM" : { + }, "Test Music" : { @@ -1131,6 +1144,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." : { @@ -1168,6 +1184,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/Model/ChartType.swift b/InfiniLink/Model/ChartType.swift index bdaa0fe..f3f3a8f 100644 --- a/InfiniLink/Model/ChartType.swift +++ b/InfiniLink/Model/ChartType.swift @@ -19,7 +19,9 @@ enum ChartType: String { case .heart: return "heart.fill" case .battery: - return "battery.20.fill" + return "battery.50percent.fill" + case .sleep: + return "moon.fill" } } } diff --git a/InfiniLink/Utils/ChartManager.swift b/InfiniLink/Utils/ChartManager.swift index 11b5c91..72a3d4d 100644 --- a/InfiniLink/Utils/ChartManager.swift +++ b/InfiniLink/Utils/ChartManager.swift @@ -10,9 +10,6 @@ import SwiftUI import CoreData class ChartManager: ObservableObject { - @AppStorage("heartRateChartDataSelection") var heartRateChartDataSelection = 0 - @AppStorage("stepChartDataSelection") var stepChartDataSelection = 0 - static let shared = ChartManager() let persistenceController = PersistenceController.shared @@ -207,19 +204,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: