-
Notifications
You must be signed in to change notification settings - Fork 27
Heart rate chart redesign #159
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tituscmd
wants to merge
11
commits into
InfiniTimeOrg:rebuild
Choose a base branch
from
tituscmd:hrm_chart
base: rebuild
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
c9b2498
new hrm graph
tituscmd 4b59b76
change scrollable to switchable because of ios 16
tituscmd 5c73981
Revert local build dependency changes
tituscmd 27cac65
fix button behavior and ios 16 compatibility issues
tituscmd ca4fe36
revert local files... again
tituscmd 0dd7b09
fix padding cropping out left and right most bar
tituscmd 93d9bbc
add native scrollable chart for iOS 17+ users, fallback to chevron st…
tituscmd 15eb620
fix: correct heart chart Y scale and scroll position on appear and ne…
tituscmd 6ec3cdf
feat: improve heart chart header to show date range with hour (rounde…
tituscmd d38f458
missed one more tiny UX fix
tituscmd 2cb635d
chart now snaps to full days on bigger swipes
tituscmd File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,205 @@ 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 | ||
| @State private var scrollPositionDate: Date = Date() | ||
|
|
||
| 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 } | ||
| } | ||
|
|
||
| var visiblePoints: [HeartChartDataPoint] { | ||
| let visibleEnd = Date(timeInterval: 86400, since: scrollPositionDate) | ||
| return points.filter { $0.date >= scrollPositionDate && $0.date <= visibleEnd } | ||
| } | ||
| var visibleMin: Int { | ||
| Int(visiblePoints.map({ $0.min }).min() ?? 0) | ||
| } | ||
| var visibleMax: Int { | ||
| Int(visiblePoints.map({ $0.max }).max() ?? 0) | ||
| } | ||
|
|
||
| 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: Calendar.current.date(byAdding: .minute, value: 30, to: bucket) ?? bucket, | ||
| min: values.min() ?? 0, | ||
| max: values.max() ?? 0, | ||
| average: { | ||
| 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 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() | ||
| } | ||
|
|
||
| let heartColor = Color(red: 0.996, green: 0.212, blue: 0.369) | ||
| let darkHeartColor = Color(red: 0.369, green: 0.090, blue: 0.145) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are they any built in swiftui colors we can use without declaring our own? |
||
|
|
||
| func isSingleReading(_ point: HeartChartDataPoint) -> Bool { | ||
| point.min == point.max | ||
| } | ||
|
|
||
| func updateYScale() { | ||
| displayedMin = visibleMin | ||
| displayedMax = visibleMax | ||
| } | ||
|
|
||
| @ChartContentBuilder | ||
| func chartContent(for point: 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) | ||
| //.clipShape(Capsule()) | ||
|
|
||
| PointMark( | ||
| x: .value("Time", point.date), | ||
| y: .value("BPM", point.average) | ||
| ) | ||
| .foregroundStyle(heartColor) | ||
| .symbolSize(CGSize(width: 7, height: 7)) | ||
| .symbol(.circle) | ||
| } | ||
|
|
||
| // fixed graph | ||
| func updateDisplayed() { | ||
| displayedDate = windowStart | ||
| displayedMin = Int(windowPoints.map({ $0.min }).min() ?? 0) | ||
| displayedMax = Int(windowPoints.map({ $0.max }).max() ?? 0) | ||
| } | ||
|
|
||
| // MARK: iOS 16- fixed chart | ||
| func chartPage(for offset: Int) -> some View { | ||
| let start = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: offset, to: Date())!) | ||
| let end = Date(timeInterval: 86400, since: start) | ||
| let pagePoints = points.filter { $0.date >= start && $0.date <= end } | ||
| let pageMin = Int(pagePoints.map({ $0.min }).min() ?? 0) | ||
| let pageMax = Int(pagePoints.map({ $0.max }).max() ?? 0) | ||
|
|
||
| return Chart { | ||
| ForEach(pagePoints) { point in | ||
| chartContent(for: point) | ||
| } | ||
| } | ||
| .frame(height: 280) | ||
| .padding(.horizontal, 8) | ||
| .chartYScale(domain: (pageMin - 20)...(pageMax + 20)) | ||
| .chartXScale(domain: start...end) | ||
| .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() | ||
| } | ||
| } | ||
| } | ||
| var max: Int { | ||
| return Int(points.compactMap({ $0.value }).max() ?? 0) | ||
|
|
||
| var pagedChart: some View { | ||
| VStack(spacing: 0) { | ||
| HStack { | ||
| Button { | ||
| dayOffset -= 1 | ||
| } label: { | ||
| Image(systemName: "chevron.left") | ||
| } | ||
| .disabled(Calendar.current.isDate(windowStart, inSameDayAs: earliestDate)) | ||
|
|
||
| Spacer() | ||
|
|
||
| Button { | ||
| dayOffset += 1 | ||
| } label: { | ||
| Image(systemName: "chevron.right") | ||
| } | ||
| .disabled(dayOffset >= 0) | ||
| } | ||
| .padding(.horizontal, 12) | ||
| .padding(.vertical, 8) | ||
| .background(Color(.secondarySystemGroupedBackground)) | ||
| .clipShape(Capsule()) | ||
| .padding(.bottom, 8) | ||
|
|
||
| chartPage(for: dayOffset) | ||
| } | ||
| } | ||
| var min: Int { | ||
| return Int(points.compactMap({ $0.value }).min() ?? 0) | ||
|
|
||
| // MARK: iOS 17+ scrollable chart | ||
| @available(iOS 17, *) | ||
| var scrollableChart: some View { | ||
| let xMin = Calendar.current.startOfDay(for: earliestDate) | ||
| //let xMax = Calendar.current.date(byAdding: .day, value: 2, to: Calendar.current.startOfDay(for: latestDate)) ?? latestDate | ||
| let xMax = Calendar.current.startOfDay(for: latestDate) + 86400 + 3600 | ||
| let yMin = displayedMin - 20 | ||
| let yMax = displayedMax + 20 | ||
|
|
||
| return Chart { | ||
| ForEach(points) { point in | ||
| chartContent(for: point) | ||
| } | ||
| } | ||
| .frame(height: 280) | ||
| .padding(.horizontal, 8) | ||
| .chartYScale(domain: (yMin...yMax)) | ||
| .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() | ||
| } | ||
| } | ||
| .chartScrollableAxes(.horizontal) | ||
| .chartXVisibleDomain(length: 86400) | ||
| .chartXScale(domain: (xMin...xMax)) | ||
| .chartScrollPosition(x: $scrollPositionDate) | ||
| .chartScrollTargetBehavior( | ||
| .valueAligned( | ||
| matching: DateComponents(timeZone: .current, minute: 0, second: 0), | ||
| majorAlignment: .matching(DateComponents(timeZone: .current, hour: 0)) | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| var body: some View { | ||
|
|
@@ -46,49 +233,81 @@ struct HeartChartView: View { | |
| EmptyChartView(.heart) | ||
| } else { | ||
| Section { | ||
| Chart(points) { point in | ||
| PointMark( | ||
| x: .value("Time", point.date), | ||
| y: .value("BPM", point.value) | ||
| ) | ||
| .clipShape(Capsule()) | ||
| .foregroundStyle(Color.red) | ||
| VStack(spacing: 0) { | ||
| if #available(iOS 17, *) { | ||
| scrollableChart | ||
| } else { | ||
| pagedChart | ||
| } | ||
| } | ||
| .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()))") | ||
| let rounded = Date(timeIntervalSinceReferenceDate: (scrollPositionDate.timeIntervalSinceReferenceDate / 3600).rounded() * 3600) | ||
| let end = Date(timeInterval: 86400, since: rounded) | ||
| let isFullDay = Calendar.current.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) | ||
| } | ||
| .listRowInsets(EdgeInsets(top: 18, leading: 0, bottom: 0, trailing: 0)) | ||
| } | ||
| } | ||
| .listRowBackground(Color.clear) | ||
| if points.count >= 3 { | ||
| /* | ||
| 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() | ||
| scrollPositionDate = Calendar.current.startOfDay(for: latestDate) | ||
| displayedDate = scrollPositionDate | ||
| updateDisplayed() | ||
| updateYScale() | ||
| } | ||
| .onChange(of: bleManager.heartRate) { _ in | ||
| let previousLatest = latestDate | ||
| let wasAtLatest = scrollPositionDate >= Date(timeInterval: -86400, since: previousLatest) | ||
| points = heartPoints() | ||
| if Calendar.current.component(.hour, from: latestDate) > Calendar.current.component(.hour, from: previousLatest) { | ||
| if wasAtLatest { | ||
| scrollPositionDate = Date(timeInterval: -86400, since: latestDate) | ||
| } | ||
| } | ||
| updateDisplayed() | ||
| updateYScale() // scrollable chart | ||
| } | ||
| // scrollable graph | ||
| .onChange(of: scrollPositionDate) { newValue in | ||
| displayedDate = newValue | ||
| } | ||
| .onChange(of: scrollPositionDate) { newValue in | ||
| Task { | ||
| try? await Task.sleep(nanoseconds: 300_000_000) | ||
|
liamcharger marked this conversation as resolved.
|
||
| if scrollPositionDate == newValue { | ||
| updateYScale() | ||
| } | ||
| } | ||
| } | ||
| // fixed graph | ||
| .onChange(of: dayOffset) { _ in | ||
| updateDisplayed() | ||
| } | ||
| } | ||
| } | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm we're doing a lot of computation here. I wonder if any of this can be avoided. For example, we already compute the min, max, and avg of the values in the header view
I'm just not a huge fan of how many computed properties we have in this view. However, I may just be being picky :)