Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
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
313 changes: 265 additions & 48 deletions InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
}
}
}
18 changes: 0 additions & 18 deletions InfiniLink/Core/HeartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
8 changes: 8 additions & 0 deletions InfiniLink/Core/Settings/HeartSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<HeartDataPoint>

Expand Down Expand Up @@ -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: {
Expand Down
Loading