Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
fa1269e
new hrm graph
tituscmd May 4, 2026
7febcaf
change scrollable to switchable because of ios 16
tituscmd May 5, 2026
0abf19f
Revert local build dependency changes
tituscmd May 5, 2026
52849db
fix button behavior and ios 16 compatibility issues
tituscmd May 5, 2026
ddbd2f6
revert local files... again
tituscmd May 5, 2026
8f050d1
fix padding cropping out left and right most bar
tituscmd May 5, 2026
016305d
add native scrollable chart for iOS 17+ users, fallback to chevron st…
tituscmd May 6, 2026
e9aea7f
fix: correct heart chart Y scale and scroll position on appear and ne…
tituscmd May 8, 2026
74e86e8
feat: improve heart chart header to show date range with hour (rounde…
tituscmd May 8, 2026
7175691
missed one more tiny UX fix
tituscmd May 8, 2026
9b4eabf
chart now snaps to full days on bigger swipes
tituscmd May 11, 2026
882d22c
Update InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift
tituscmd May 13, 2026
8724246
range bars are selectable and show detailed info in header
tituscmd May 12, 2026
8fd9068
feat: add tiny vibration on swiping through bars with slider
tituscmd May 13, 2026
b0ec58c
finalize slider for scrollable graph
tituscmd May 13, 2026
311d7f8
finalize slider to work on both charts and make it prettier
tituscmd May 13, 2026
74b1f5c
remove some redundant computations and banner above heart rate graph …
tituscmd May 13, 2026
d3379f8
remove now outdated line from readme
tituscmd May 13, 2026
f599c58
Clean up
liamcharger May 14, 2026
68d383c
Merge branch 'rebuild' into hrm_chart
tituscmd May 14, 2026
718ffb6
fix some stuff broken by cleanup commit
tituscmd May 14, 2026
81dcd93
apply review changes and tweak colors
tituscmd May 14, 2026
17302ce
First iteration of dynamic fetch
liamcharger May 14, 2026
4008158
Merge branch 'rebuild' into hrm_chart
tituscmd May 14, 2026
35b4cbd
Next iteration of improved data fetch
liamcharger May 15, 2026
4e43916
Fix INL-44 and INL-26
liamcharger May 15, 2026
7464ac8
bugfix: phone low battery notifs delivering more than once
liamcharger May 17, 2026
9c40e98
Fix occasional dropped dfu updates
liamcharger May 17, 2026
5ff5a86
Add force ancs toggle flag
liamcharger May 17, 2026
d5db991
Merge branch 'rebuild' into hrm_chart
tituscmd May 17, 2026
620db0c
Merge branch 'rebuild' into hrm_chart
liamcharger May 19, 2026
82de20a
Merge branch 'rebuild' into hrm_chart
liamcharger May 19, 2026
8a08201
Clean up
liamcharger May 19, 2026
a5a0a11
Improve hrm data fetch and add dev options
liamcharger May 19, 2026
99b823f
Merge branch 'rebuild' into hrm_chart
tituscmd May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
348 changes: 309 additions & 39 deletions InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
import SwiftUI
import Charts

struct HeartChartDataPoint: Identifiable {
struct HeartChartDataPoint: Identifiable, Equatable {
var id = UUID()
let date: Date
let value: Double
let min: Double
let max: Double
let median: Double
let values: [Double]
}

struct HeartChartView: View {
Expand All @@ -22,73 +25,340 @@ 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()
@State private var rawSelectedHour: Date? = nil

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(
Comment thread
tituscmd marked this conversation as resolved.
Outdated
date: Calendar.current.date(byAdding: .minute, value: 30, to: bucket) ?? bucket,
min: values.min() ?? 0,
max: values.max() ?? 0,
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 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.pink
let darkHeartColor = Color(red: 0.369, green: 0.090, blue: 0.145) // darkened version of heartColor

func isSingleReading(_ point: HeartChartDataPoint) -> Bool {
point.min == point.max
}

func updateYScale() {
displayedMin = visibleMin
displayedMax = visibleMax
}
var max: Int {
return Int(points.compactMap({ $0.value }).max() ?? 0)

var selectedViewHour: HeartChartDataPoint? {
guard let rawSelectedHour else { return nil }
return points.first {
Calendar.current.isDate(rawSelectedHour, equalTo: $0.date, toGranularity: .hour)
}
}

@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.5)

PointMark(
x: .value("Time", point.date),
y: .value("BPM", point.median)
)
.foregroundStyle(heartColor)
.symbolSize(CGSize(width: 7, height: 7))
.symbol(.circle)
.opacity(selected == nil || selected?.date == point.date ? 1 : 0.25)
}
var min: Int {
return Int(points.compactMap({ $0.value }).min() ?? 0)

// 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 xMin = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: offset, to: Date())!)
let xMax = Date(timeInterval: 86400, since: xMin)
let yMin = displayedMin - 20
let yMax = displayedMax + 20

return Chart {
if let selectedViewHour {
RuleMark(x: .value("Selected Hour", selectedViewHour.date, unit: .hour))
.foregroundStyle(Color.gray)
}

ForEach(windowPoints) { point in
chartContent(for: point, selected: selectedViewHour)
}
}
.frame(height: 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()
}
}
.overlay(
GeometryReader { geo in
Color.clear
.contentShape(Rectangle())
.gesture(DragGesture(minimumDistance: 0)
.onChanged { value in
let adjustedWidth = geo.size.width - 48 // 40 y-axis + 8 horizontal padding
let normalizedXPosition = min(max(value.location.x - 8, 0), adjustedWidth) / adjustedWidth
rawSelectedHour = xMin.addingTimeInterval(normalizedXPosition * 86400)
}
.onEnded { _ in
rawSelectedHour = nil
}
)
}
)
}

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)
}
}

// MARK: iOS 17+ scrollable chart
@available(iOS 17, *)
var scrollableChart: some View {
let xMin = Calendar.current.startOfDay(for: earliestDate)
let xMax = Calendar.current.startOfDay(for: latestDate) + 86400 + 3600 // + one day and an hour, fixes the snappy scrolling otherwise breaking sometimes
let yMin = displayedMin - 20
let yMax = displayedMax + 20

return 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(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))
)
)
.chartXSelection(value: $rawSelectedHour)
}

var body: some View {
Group {
Group {
if points.count <= 1 {
if points.flatMap({ $0.values }).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)
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("BPM")
Text("\(earliestDate.formatted(.dateTime.month(.abbreviated).day()))-\(latestDate.formatted(.dateTime.day()))")
if let selectedViewHour {
let rangeFirstHour = Calendar.current.dateInterval(of: .hour, for: selectedViewHour.date)?.start ?? selectedViewHour.date
let rangeLastHour = Calendar.current.date(byAdding: .hour, value: 1, to: rangeFirstHour) ?? rangeFirstHour

VStack(alignment: .leading) {
Text("Range")
.font(.caption)
.foregroundColor(.secondary)
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(selectedViewHour.median)) BPM avg" : "")")
.foregroundColor(.secondary)
.font(.subheadline)
}
.fontWeight(.semibold)
} else {
VStack(alignment: .leading) {
Text("Range")
.font(.caption)
.foregroundColor(.secondary)
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 = 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)
}
.fontWeight(.semibold)
}

.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()))")
}
}
}
.onAppear {
points = heartPoints()
scrollPositionDate = Calendar.current.startOfDay(for: latestDate)
displayedDate = scrollPositionDate
updateDisplayed()
updateYScale()
}
.onChange(of: bleManager.heartRate) { _ in
let previousLatest = latestDate
points = heartPoints()
if !Calendar.current.isDate(latestDate, inSameDayAs: previousLatest) {
dayOffset = 0
scrollPositionDate = Calendar.current.startOfDay(for: latestDate)
}
Comment thread
liamcharger marked this conversation as resolved.
Outdated
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)
Comment thread
liamcharger marked this conversation as resolved.
Outdated
if scrollPositionDate == newValue {
updateYScale()
}
}
}
// fixed graph
.onChange(of: dayOffset) { _ in
updateDisplayed()
}
.onChange(of: selectedViewHour) { newValue in
guard newValue != nil else { return }
UIImpactFeedbackGenerator(style: .light).impactOccurred()
Comment thread
tituscmd marked this conversation as resolved.
Outdated
}
}
}

Loading