Skip to content

Commit 7a56436

Browse files
tituscmdliamcharger
authored andcommitted
add native scrollable chart for iOS 17+ users, fallback to chevron style otherwise
1 parent 3e530b7 commit 7a56436

1 file changed

Lines changed: 132 additions & 56 deletions

File tree

InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift

Lines changed: 132 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ struct HeartChartView: View {
2929
@State private var displayedDate: Date = Date()
3030
@State private var displayedMin: Int = 0
3131
@State private var displayedMax: Int = 0
32+
@State private var scrollPositionDate: Date = Date()
3233

3334
var windowStart: Date {
3435
Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: dayOffset, to: Date())!)
@@ -40,6 +41,17 @@ struct HeartChartView: View {
4041
points.filter { $0.date >= windowStart && $0.date <= windowEnd }
4142
}
4243

44+
var visiblePoints: [HeartChartDataPoint] {
45+
let visibleEnd = Date(timeInterval: 86400, since: scrollPositionDate)
46+
return points.filter { $0.date >= scrollPositionDate && $0.date <= visibleEnd }
47+
}
48+
var visibleMin: Int {
49+
Int(visiblePoints.map({ $0.min }).min() ?? 0)
50+
}
51+
var visibleMax: Int {
52+
Int(visiblePoints.map({ $0.max }).max() ?? 0)
53+
}
54+
4355
func heartPoints() -> [HeartChartDataPoint] {
4456
let raw = ChartManager.shared.heartPoints()
4557

@@ -51,10 +63,16 @@ struct HeartChartView: View {
5163
return grouped.map { (bucket, samples) in
5264
let values = samples.map { $0.value }
5365
return HeartChartDataPoint(
54-
date: bucket,
66+
date: Calendar.current.date(byAdding: .minute, value: 30, to: bucket) ?? bucket,
5567
min: values.min() ?? 0,
5668
max: values.max() ?? 0,
57-
average: values.reduce(0, +) / Double(values.count),
69+
average: {
70+
let sorted = values.sorted()
71+
let mid = sorted.count / 2
72+
return sorted.count % 2 == 0
73+
? (sorted[mid - 1] + sorted[mid]) / 2
74+
: sorted[mid]
75+
}(),
5876
values: values
5977
)
6078
}.sorted { $0.date < $1.date }
@@ -110,6 +128,94 @@ struct HeartChartView: View {
110128
displayedMax = Int(windowPoints.map({ $0.max }).max() ?? 0)
111129
}
112130

131+
func chartPage(for offset: Int) -> some View {
132+
let start = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: offset, to: Date())!)
133+
let end = Date(timeInterval: 86400, since: start)
134+
let pagePoints = points.filter { $0.date >= start && $0.date <= end }
135+
let pageMin = Int(pagePoints.map({ $0.min }).min() ?? 0)
136+
let pageMax = Int(pagePoints.map({ $0.max }).max() ?? 0)
137+
138+
return Chart {
139+
ForEach(pagePoints) { point in
140+
chartContent(for: point)
141+
}
142+
}
143+
.frame(height: 280)
144+
.padding(.horizontal, 8)
145+
.chartYScale(domain: (pageMin - 20)...(pageMax + 20))
146+
.chartXScale(domain: start...end)
147+
.chartXAxis {
148+
AxisMarks(values: .stride(by: .hour, count: 6)) { value in
149+
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [4]))
150+
AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .omitted)))
151+
}
152+
}
153+
.chartYAxis {
154+
AxisMarks(position: .trailing) { value in
155+
AxisGridLine()
156+
AxisValueLabel()
157+
}
158+
}
159+
}
160+
161+
var pagedChart: some View {
162+
VStack(spacing: 0) {
163+
HStack {
164+
Button {
165+
dayOffset -= 1
166+
} label: {
167+
Image(systemName: "chevron.left")
168+
}
169+
.disabled(Calendar.current.isDate(windowStart, inSameDayAs: earliestDate))
170+
171+
Spacer()
172+
173+
Button {
174+
dayOffset += 1
175+
} label: {
176+
Image(systemName: "chevron.right")
177+
}
178+
.disabled(dayOffset >= 0)
179+
}
180+
.padding(.horizontal, 12)
181+
.padding(.vertical, 8)
182+
.background(Color(.secondarySystemGroupedBackground))
183+
.clipShape(Capsule())
184+
.padding(.bottom, 8)
185+
186+
chartPage(for: dayOffset)
187+
}
188+
}
189+
190+
@available(iOS 17, *)
191+
var scrollableChart: some View {
192+
Chart {
193+
ForEach(points) { point in
194+
chartContent(for: point)
195+
}
196+
}
197+
.frame(height: 280)
198+
.padding(.horizontal, 8)
199+
.chartYScale(domain: (displayedMin - 20)...(displayedMax + 20))
200+
.chartXAxis {
201+
AxisMarks(values: .stride(by: .hour, count: 6)) { value in
202+
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [4]))
203+
AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .omitted)))
204+
}
205+
}
206+
.chartYAxis {
207+
AxisMarks(position: .trailing) { value in
208+
AxisGridLine()
209+
AxisValueLabel()
210+
}
211+
}
212+
.chartScrollableAxes(.horizontal)
213+
.chartXVisibleDomain(length: 86400)
214+
.chartXScale(domain: (earliestDate - 1800)...(latestDate + 1800))
215+
.chartScrollPosition(x: $scrollPositionDate)
216+
.chartScrollTargetBehavior(.valueAligned(unit: 3600))
217+
}
218+
113219
var body: some View {
114220
Group {
115221
Group {
@@ -118,54 +224,10 @@ struct HeartChartView: View {
118224
} else {
119225
Section {
120226
VStack(spacing: 0) {
121-
HStack {
122-
Button {
123-
dayOffset -= 1
124-
} label: {
125-
Image(systemName: "chevron.left")
126-
}
127-
.disabled(Calendar.current.isDate(windowStart, inSameDayAs: earliestDate))
128-
129-
Spacer()
130-
131-
Text(displayedDate.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day().year()))
132-
.foregroundColor(.primary)
133-
134-
Spacer()
135-
136-
Button {
137-
dayOffset += 1
138-
} label: {
139-
Image(systemName: "chevron.right")
140-
}
141-
.disabled(dayOffset >= 0)
142-
}
143-
.padding(.horizontal, 12)
144-
.padding(.vertical, 8)
145-
.background(Color(.secondarySystemGroupedBackground))
146-
.clipShape(Capsule())
147-
.padding(.bottom, 8)
148-
149-
Chart {
150-
ForEach(windowPoints) { point in
151-
chartContent(for: point)
152-
}
153-
}
154-
.frame(height: 280)
155-
.padding(.horizontal, 8)
156-
.chartYScale(domain: (displayedMin - 20)...(displayedMax + 20))
157-
.chartXScale(domain: windowStart...windowEnd)
158-
.chartXAxis {
159-
AxisMarks(values: .stride(by: .hour, count: 6)) { value in
160-
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [4]))
161-
AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .omitted)))
162-
}
163-
}
164-
.chartYAxis {
165-
AxisMarks(position: .trailing) { value in
166-
AxisGridLine()
167-
AxisValueLabel()
168-
}
227+
if #available(iOS 17, *) {
228+
scrollableChart
229+
} else {
230+
pagedChart
169231
}
170232
}
171233
.buttonStyle(.plain)
@@ -178,6 +240,9 @@ struct HeartChartView: View {
178240
.font(.system(.title, design: .rounded))
179241
.foregroundColor(.primary)
180242
+ Text("BPM")
243+
Text(displayedDate.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day().year()))
244+
.foregroundColor(.secondary)
245+
.font(.subheadline)
181246
}
182247
.fontWeight(.semibold)
183248
}
@@ -193,20 +258,31 @@ struct HeartChartView: View {
193258
}
194259
.onAppear {
195260
points = heartPoints()
261+
scrollPositionDate = windowStart
196262
updateDisplayed()
197263
}
198264
.onChange(of: dayOffset) { _ in
199265
updateDisplayed()
200266
}
267+
.onChange(of: scrollPositionDate) { newValue in
268+
displayedDate = newValue
269+
}
270+
.onChange(of: scrollPositionDate) { newValue in
271+
Task {
272+
try? await Task.sleep(nanoseconds: 300_000_000)
273+
if scrollPositionDate == newValue {
274+
let clamped = min(max(newValue, Calendar.current.startOfDay(for: earliestDate)), Calendar.current.startOfDay(for: latestDate))
275+
if clamped != newValue {
276+
scrollPositionDate = clamped
277+
}
278+
displayedMin = visibleMin
279+
displayedMax = visibleMax
280+
}
281+
}
282+
}
201283
.onChange(of: bleManager.heartRate) { _ in
202284
points = heartPoints()
203285
updateDisplayed()
204286
}
205287
}
206288
}
207-
208-
#Preview {
209-
List {
210-
HeartChartView()
211-
}
212-
}

0 commit comments

Comments
 (0)