From fa1269e93aef9f3a7cb9aa1b59a237652b4cf064 Mon Sep 17 00:00:00 2001 From: titus Date: Mon, 4 May 2026 20:57:10 +0200 Subject: [PATCH 01/23] new hrm graph --- InfiniLink.xcodeproj/project.pbxproj | 12 +- .../Charts/Heart/HeartChartView.swift | 155 ++++++++++++++---- InfiniLink/InfiniLink.entitlements | 20 +-- InfiniLink/Info.plist | 4 - InfiniLink/Localizable.xcstrings | 29 ++-- InfiniLink/Utils/ChartManager.swift | 2 +- InfiniLink/Utils/HealthKitManager.swift | 4 +- 7 files changed, 147 insertions(+), 79 deletions(-) diff --git a/InfiniLink.xcodeproj/project.pbxproj b/InfiniLink.xcodeproj/project.pbxproj index f6dd403..c76f6d2 100644 --- a/InfiniLink.xcodeproj/project.pbxproj +++ b/InfiniLink.xcodeproj/project.pbxproj @@ -1031,17 +1031,17 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"InfiniLink/Preview Content\""; - DEVELOPMENT_TEAM = UXA3H4X42S; + DEVELOPMENT_TEAM = 6H5DHLRX53; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = InfiniLink/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 16; + IPHONEOS_DEPLOYMENT_TARGET = 17; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.2; - PRODUCT_BUNDLE_IDENTIFIER = "com.alexemry.Infini-iOS"; + PRODUCT_BUNDLE_IDENTIFIER = com.titusk.InfiniLink; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1063,17 +1063,17 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"InfiniLink/Preview Content\""; - DEVELOPMENT_TEAM = UXA3H4X42S; + DEVELOPMENT_TEAM = 6H5DHLRX53; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = InfiniLink/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 16; + IPHONEOS_DEPLOYMENT_TARGET = 17; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.2; - PRODUCT_BUNDLE_IDENTIFIER = "com.alexemry.Infini-iOS"; + PRODUCT_BUNDLE_IDENTIFIER = com.titusk.InfiniLink; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index 3edf34c..5f3afa5 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -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,92 @@ struct HeartChartView: View { @AppStorage("maxHeartRange") private var maxHeartRange = 200 @State private var points = [HeartChartDataPoint]() + @State private var scrollPosition: Date = Date(timeInterval: -86400, since: Date()) + @State private var displayedDate: Date = Date() + @State private var displayedMin: Int = 0 + @State private var displayedMax: Int = 0 + + var visiblePoints: [HeartChartDataPoint] { + let windowStart = scrollPosition + let windowEnd = Date(timeInterval: 86400, since: scrollPosition) + return points.filter { $0.date >= windowStart && $0.date <= windowEnd } + } + + var visibleMax: Int { + Int(visiblePoints.map({ $0.max }).max() ?? 200) + } + var visibleMin: Int { + Int(visiblePoints.map({ $0.min }).min() ?? 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: bucket, + min: values.min() ?? 0, + max: values.max() ?? 0, + average: values.reduce(0, +) / Double(values.count), + 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() + } + var overallMax: Int { + Int(points.map({ $0.max }).max() ?? 0) } - var max: Int { - return Int(points.compactMap({ $0.value }).max() ?? 0) + var overallMin: Int { + Int(points.map({ $0.min }).min() ?? 0) + } + + let heartColor = Color(red: 0.996, green: 0.212, blue: 0.369) + let darkHeartColor = Color(red: 0.369, green: 0.090, blue: 0.145) + + func isSingleReading(_ point: HeartChartDataPoint) -> Bool { + point.min == point.max } - var min: Int { - return Int(points.compactMap({ $0.value }).min() ?? 0) + + @ChartContentBuilder + func chartContent(for point: HeartChartDataPoint) -> some ChartContent { + if isSingleReading(point) { + PointMark( + x: .value("Time", point.date), + y: .value("BPM", point.min) + ) + .foregroundStyle(heartColor) + .symbolSize(40) + .symbol(.circle) + } else { + RectangleMark( + x: .value("Time", point.date), + yStart: .value("Min", point.min), + yEnd: .value("Max", point.max), + width: 7 + ) + .foregroundStyle(darkHeartColor) + .clipShape(Capsule()) + + PointMark( + x: .value("Time", point.date), + y: .value("BPM", point.average) + ) + .foregroundStyle(heartColor) + .symbolSize(CGSize(width: 7, height: 7)) + .symbol(.circle) + } } var body: some View { @@ -46,30 +120,44 @@ 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) + Chart { + ForEach(points) { point in + chartContent(for: point) + } } .frame(height: 280) - .chartYScale(domain: minHeartRange...maxHeartRange) + .chartYScale(domain: (displayedMin - 20)...(displayedMax + 20)) + .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))) + } + } + .chartScrollableAxes(.horizontal) + .chartXVisibleDomain(length: 86400) + .chartScrollPosition(x: $scrollPosition) + .onChange(of: scrollPosition) { newValue in + Task { + try? await Task.sleep(nanoseconds: 300_000_000) + if scrollPosition == newValue { + displayedDate = newValue + displayedMin = visibleMin + displayedMax = visibleMax + } + } + } } 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()))") + Text(displayedDate.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day().year())) + .foregroundColor(.secondary) + .font(.subheadline) } .fontWeight(.semibold) } @@ -79,16 +167,25 @@ struct HeartChartView: View { .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()))") + Text("Today your heart rate reached a high of \(displayedMax), and dropped to a low of \(displayedMin) BPM.") } } } .onAppear { points = heartPoints() + scrollPosition = Date(timeInterval: -86400, since: latestDate) + displayedDate = latestDate + displayedMin = visibleMin + displayedMax = visibleMax } .onChange(of: bleManager.heartRate) { _ in points = heartPoints() } } } + +#Preview { + List { + HeartChartView() + } +} diff --git a/InfiniLink/InfiniLink.entitlements b/InfiniLink/InfiniLink.entitlements index c5f2828..cce046c 100644 --- a/InfiniLink/InfiniLink.entitlements +++ b/InfiniLink/InfiniLink.entitlements @@ -2,28 +2,10 @@ - aps-environment - development - com.apple.developer.healthkit - - com.apple.developer.healthkit.background-delivery - - com.apple.developer.icloud-container-identifiers - - iCloud.com.alexemry.Infini-iOS - - com.apple.developer.icloud-services - - CloudKit - - com.apple.developer.weatherkit - com.apple.security.app-sandbox com.apple.security.application-groups - - group.com.alexemry.Infini-iOS - + com.apple.security.device.bluetooth com.apple.security.files.user-selected.read-only diff --git a/InfiniLink/Info.plist b/InfiniLink/Info.plist index 95df6d2..d08cd50 100644 --- a/InfiniLink/Info.plist +++ b/InfiniLink/Info.plist @@ -36,10 +36,6 @@ This app uses bluetooth to communicate with your PineTime. NSCalendarsUsageDescription This app needs access to your calendars to receive event reminders on your watch. - NSHealthShareUsageDescription - This app optionally uses Apple Health to write your heart rate and step count data. - NSHealthUpdateUsageDescription - This app optionally uses Apple Health to write your heart rate and step count data. NSLocationAlwaysAndWhenInUseUsageDescription This app needs your location to receive local weather information when the app is in the background. NSLocationAlwaysUsageDescription diff --git a/InfiniLink/Localizable.xcstrings b/InfiniLink/Localizable.xcstrings index c7caea6..4ee9075 100644 --- a/InfiniLink/Localizable.xcstrings +++ b/InfiniLink/Localizable.xcstrings @@ -97,16 +97,6 @@ } } }, - "%@-%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@-%2$@" - } - } - } - }, "%@:%@:%@" : { "localizations" : { "en" : { @@ -175,18 +165,18 @@ } } }, - "%lld-%lld " : { + "%lld-day" : { + + }, + "%lld–%lld " : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$lld-%2$lld " + "value" : "%1$lld–%2$lld " } } } - }, - "%lld-day" : { - }, "%lld%%" : { @@ -819,6 +809,9 @@ } } } + }, + "Max" : { + }, "Maximum" : { "localizations" : { @@ -832,6 +825,9 @@ }, "Metric" : { + }, + "Min" : { + }, "Minimum" : { @@ -869,9 +865,6 @@ }, "No" : { - }, - "No Data" : { - }, "No Logs" : { diff --git a/InfiniLink/Utils/ChartManager.swift b/InfiniLink/Utils/ChartManager.swift index f635bae..b806da7 100644 --- a/InfiniLink/Utils/ChartManager.swift +++ b/InfiniLink/Utils/ChartManager.swift @@ -108,7 +108,7 @@ class ChartManager: ObservableObject { func heartPoints(predicate: NSPredicate? = nil) -> [HeartDataPoint] { let fetchRequest: NSFetchRequest = HeartDataPoint.fetchRequest() - fetchRequest.predicate = predicate ?? dayPredicate + fetchRequest.predicate = predicate ?? weekPredicate do { return try persistenceController.container.viewContext.fetch(fetchRequest) diff --git a/InfiniLink/Utils/HealthKitManager.swift b/InfiniLink/Utils/HealthKitManager.swift index 7a0e702..9c2e70e 100644 --- a/InfiniLink/Utils/HealthKitManager.swift +++ b/InfiniLink/Utils/HealthKitManager.swift @@ -91,10 +91,10 @@ class HealthKitManager: ObservableObject { guard let healthStore = self.healthStore else { return } - healthStore.requestAuthorization(toShare: [stepsType, heartRateType, caloriesType, workoutType], read: [stepsType, heartRateType]) { success, error in + /*healthStore.requestAuthorization(toShare: [stepsType, heartRateType, caloriesType, workoutType], read: [stepsType, heartRateType]) { success, error in if let error = error { log(error.localizedDescription, caller: "HealthKitManager") } - } + }*/ } } From 7febcaffc8a8a0ae39f7d85ee905928e3b2406bc Mon Sep 17 00:00:00 2001 From: titus Date: Tue, 5 May 2026 05:57:44 +0200 Subject: [PATCH 02/23] change scrollable to switchable because of ios 16 --- .../Charts/Heart/HeartChartView.swift | 109 ++++++++++-------- 1 file changed, 64 insertions(+), 45 deletions(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index 5f3afa5..803ea76 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -25,22 +25,19 @@ struct HeartChartView: View { @AppStorage("maxHeartRange") private var maxHeartRange = 200 @State private var points = [HeartChartDataPoint]() - @State private var scrollPosition: Date = Date(timeInterval: -86400, since: Date()) + @State private var dayOffset: Int = 0 @State private var displayedDate: Date = Date() @State private var displayedMin: Int = 0 @State private var displayedMax: Int = 0 - var visiblePoints: [HeartChartDataPoint] { - let windowStart = scrollPosition - let windowEnd = Date(timeInterval: 86400, since: scrollPosition) - return points.filter { $0.date >= windowStart && $0.date <= windowEnd } + var windowStart: Date { + Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: dayOffset, to: Date())!) } - - var visibleMax: Int { - Int(visiblePoints.map({ $0.max }).max() ?? 200) + var windowEnd: Date { + Date(timeInterval: 86400, since: windowStart) } - var visibleMin: Int { - Int(visiblePoints.map({ $0.min }).min() ?? 0) + var windowPoints: [HeartChartDataPoint] { + points.filter { $0.date >= windowStart && $0.date <= windowEnd } } func heartPoints() -> [HeartChartDataPoint] { @@ -69,12 +66,6 @@ struct HeartChartView: View { var latestDate: Date { points.map({ $0.date }).max() ?? Date() } - var overallMax: Int { - Int(points.map({ $0.max }).max() ?? 0) - } - var overallMin: Int { - Int(points.map({ $0.min }).min() ?? 0) - } let heartColor = Color(red: 0.996, green: 0.212, blue: 0.369) let darkHeartColor = Color(red: 0.369, green: 0.090, blue: 0.145) @@ -113,6 +104,12 @@ struct HeartChartView: View { } } + func updateDisplayed() { + displayedDate = windowStart + displayedMin = Int(windowPoints.map({ $0.min }).min() ?? 0) + displayedMax = Int(windowPoints.map({ $0.max }).max() ?? 0) + } + var body: some View { Group { Group { @@ -120,29 +117,53 @@ struct HeartChartView: View { EmptyChartView(.heart) } else { Section { - Chart { - ForEach(points) { point in - chartContent(for: point) + VStack(spacing: 0) { + HStack { + Button { + dayOffset -= 1 + } label: { + Image(systemName: "chevron.left") + } + .disabled(windowStart <= earliestDate) + + Spacer() + + Text(displayedDate.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day().year())) + .foregroundColor(.primary) + + Spacer() + + Button { + dayOffset += 1 + } label: { + Image(systemName: "chevron.right") + } + .disabled(dayOffset >= 0) } - } - .frame(height: 280) - .chartYScale(domain: (displayedMin - 20)...(displayedMax + 20)) - .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))) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(Capsule()) + .padding(.bottom, 8) + + Chart { + ForEach(windowPoints) { point in + chartContent(for: point) + } } - } - .chartScrollableAxes(.horizontal) - .chartXVisibleDomain(length: 86400) - .chartScrollPosition(x: $scrollPosition) - .onChange(of: scrollPosition) { newValue in - Task { - try? await Task.sleep(nanoseconds: 300_000_000) - if scrollPosition == newValue { - displayedDate = newValue - displayedMin = visibleMin - displayedMax = visibleMax + .frame(height: 280) + .chartYScale(domain: (displayedMin - 20)...(displayedMax + 20)) + .chartXScale(domain: windowStart...windowEnd) + .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() } } } @@ -155,9 +176,6 @@ struct HeartChartView: View { .font(.system(.title, design: .rounded)) .foregroundColor(.primary) + Text("BPM") - Text(displayedDate.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day().year())) - .foregroundColor(.secondary) - .font(.subheadline) } .fontWeight(.semibold) } @@ -173,13 +191,14 @@ struct HeartChartView: View { } .onAppear { points = heartPoints() - scrollPosition = Date(timeInterval: -86400, since: latestDate) - displayedDate = latestDate - displayedMin = visibleMin - displayedMax = visibleMax + updateDisplayed() + } + .onChange(of: dayOffset) { _, _ in + updateDisplayed() } - .onChange(of: bleManager.heartRate) { _ in + .onChange(of: bleManager.heartRate) { _, _ in points = heartPoints() + updateDisplayed() } } } From 0abf19fa7155a2190f31f97a8c4895db443b872f Mon Sep 17 00:00:00 2001 From: titus Date: Tue, 5 May 2026 06:17:16 +0200 Subject: [PATCH 03/23] Revert local build dependency changes --- InfiniLink.xcodeproj/project.pbxproj | 12 +++++----- InfiniLink/InfiniLink.entitlements | 20 ++++++++++++++++- InfiniLink/Info.plist | 4 ++++ InfiniLink/Localizable.xcstrings | 29 +++++++++++++++---------- InfiniLink/Utils/HealthKitManager.swift | 4 ++-- 5 files changed, 49 insertions(+), 20 deletions(-) diff --git a/InfiniLink.xcodeproj/project.pbxproj b/InfiniLink.xcodeproj/project.pbxproj index c76f6d2..f6dd403 100644 --- a/InfiniLink.xcodeproj/project.pbxproj +++ b/InfiniLink.xcodeproj/project.pbxproj @@ -1031,17 +1031,17 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"InfiniLink/Preview Content\""; - DEVELOPMENT_TEAM = 6H5DHLRX53; + DEVELOPMENT_TEAM = UXA3H4X42S; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = InfiniLink/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 17; + IPHONEOS_DEPLOYMENT_TARGET = 16; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.2; - PRODUCT_BUNDLE_IDENTIFIER = com.titusk.InfiniLink; + PRODUCT_BUNDLE_IDENTIFIER = "com.alexemry.Infini-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1063,17 +1063,17 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"InfiniLink/Preview Content\""; - DEVELOPMENT_TEAM = 6H5DHLRX53; + DEVELOPMENT_TEAM = UXA3H4X42S; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = InfiniLink/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 17; + IPHONEOS_DEPLOYMENT_TARGET = 16; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.2; - PRODUCT_BUNDLE_IDENTIFIER = com.titusk.InfiniLink; + PRODUCT_BUNDLE_IDENTIFIER = "com.alexemry.Infini-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/InfiniLink/InfiniLink.entitlements b/InfiniLink/InfiniLink.entitlements index cce046c..c5f2828 100644 --- a/InfiniLink/InfiniLink.entitlements +++ b/InfiniLink/InfiniLink.entitlements @@ -2,10 +2,28 @@ + aps-environment + development + com.apple.developer.healthkit + + com.apple.developer.healthkit.background-delivery + + com.apple.developer.icloud-container-identifiers + + iCloud.com.alexemry.Infini-iOS + + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.weatherkit + com.apple.security.app-sandbox com.apple.security.application-groups - + + group.com.alexemry.Infini-iOS + com.apple.security.device.bluetooth com.apple.security.files.user-selected.read-only diff --git a/InfiniLink/Info.plist b/InfiniLink/Info.plist index d08cd50..95df6d2 100644 --- a/InfiniLink/Info.plist +++ b/InfiniLink/Info.plist @@ -36,6 +36,10 @@ This app uses bluetooth to communicate with your PineTime. NSCalendarsUsageDescription This app needs access to your calendars to receive event reminders on your watch. + NSHealthShareUsageDescription + This app optionally uses Apple Health to write your heart rate and step count data. + NSHealthUpdateUsageDescription + This app optionally uses Apple Health to write your heart rate and step count data. NSLocationAlwaysAndWhenInUseUsageDescription This app needs your location to receive local weather information when the app is in the background. NSLocationAlwaysUsageDescription diff --git a/InfiniLink/Localizable.xcstrings b/InfiniLink/Localizable.xcstrings index 4ee9075..c7caea6 100644 --- a/InfiniLink/Localizable.xcstrings +++ b/InfiniLink/Localizable.xcstrings @@ -97,6 +97,16 @@ } } }, + "%@-%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@-%2$@" + } + } + } + }, "%@:%@:%@" : { "localizations" : { "en" : { @@ -165,18 +175,18 @@ } } }, - "%lld-day" : { - - }, - "%lld–%lld " : { + "%lld-%lld " : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$lld–%2$lld " + "value" : "%1$lld-%2$lld " } } } + }, + "%lld-day" : { + }, "%lld%%" : { @@ -809,9 +819,6 @@ } } } - }, - "Max" : { - }, "Maximum" : { "localizations" : { @@ -825,9 +832,6 @@ }, "Metric" : { - }, - "Min" : { - }, "Minimum" : { @@ -865,6 +869,9 @@ }, "No" : { + }, + "No Data" : { + }, "No Logs" : { diff --git a/InfiniLink/Utils/HealthKitManager.swift b/InfiniLink/Utils/HealthKitManager.swift index 9c2e70e..7a0e702 100644 --- a/InfiniLink/Utils/HealthKitManager.swift +++ b/InfiniLink/Utils/HealthKitManager.swift @@ -91,10 +91,10 @@ class HealthKitManager: ObservableObject { guard let healthStore = self.healthStore else { return } - /*healthStore.requestAuthorization(toShare: [stepsType, heartRateType, caloriesType, workoutType], read: [stepsType, heartRateType]) { success, error in + healthStore.requestAuthorization(toShare: [stepsType, heartRateType, caloriesType, workoutType], read: [stepsType, heartRateType]) { success, error in if let error = error { log(error.localizedDescription, caller: "HealthKitManager") } - }*/ + } } } From 52849db998a6684b1fdb28cf64881a8e39f97f4a Mon Sep 17 00:00:00 2001 From: titus Date: Tue, 5 May 2026 08:49:34 +0200 Subject: [PATCH 04/23] fix button behavior and ios 16 compatibility issues --- InfiniLink.xcodeproj/project.pbxproj | 8 ++--- .../Charts/Heart/HeartChartView.swift | 7 +++-- InfiniLink/InfiniLink.entitlements | 20 +------------ InfiniLink/Info.plist | 4 --- InfiniLink/Localizable.xcstrings | 29 +++++++------------ InfiniLink/Utils/HealthKitManager.swift | 4 +-- 6 files changed, 22 insertions(+), 50 deletions(-) diff --git a/InfiniLink.xcodeproj/project.pbxproj b/InfiniLink.xcodeproj/project.pbxproj index f6dd403..a209b1e 100644 --- a/InfiniLink.xcodeproj/project.pbxproj +++ b/InfiniLink.xcodeproj/project.pbxproj @@ -1031,7 +1031,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"InfiniLink/Preview Content\""; - DEVELOPMENT_TEAM = UXA3H4X42S; + DEVELOPMENT_TEAM = 6H5DHLRX53; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = InfiniLink/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -1041,7 +1041,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.2; - PRODUCT_BUNDLE_IDENTIFIER = "com.alexemry.Infini-iOS"; + PRODUCT_BUNDLE_IDENTIFIER = com.titusk.InfiniLink; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1063,7 +1063,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"InfiniLink/Preview Content\""; - DEVELOPMENT_TEAM = UXA3H4X42S; + DEVELOPMENT_TEAM = 6H5DHLRX53; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = InfiniLink/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -1073,7 +1073,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.2; - PRODUCT_BUNDLE_IDENTIFIER = "com.alexemry.Infini-iOS"; + PRODUCT_BUNDLE_IDENTIFIER = com.titusk.InfiniLink; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index 803ea76..f3746ea 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -124,7 +124,7 @@ struct HeartChartView: View { } label: { Image(systemName: "chevron.left") } - .disabled(windowStart <= earliestDate) + .disabled(Calendar.current.isDate(windowStart, inSameDayAs: earliestDate)) Spacer() @@ -167,6 +167,7 @@ struct HeartChartView: View { } } } + .buttonStyle(.plain) } header: { VStack(alignment: .leading) { Text("Range") @@ -193,10 +194,10 @@ struct HeartChartView: View { points = heartPoints() updateDisplayed() } - .onChange(of: dayOffset) { _, _ in + .onChange(of: dayOffset) { _ in updateDisplayed() } - .onChange(of: bleManager.heartRate) { _, _ in + .onChange(of: bleManager.heartRate) { _ in points = heartPoints() updateDisplayed() } diff --git a/InfiniLink/InfiniLink.entitlements b/InfiniLink/InfiniLink.entitlements index c5f2828..cce046c 100644 --- a/InfiniLink/InfiniLink.entitlements +++ b/InfiniLink/InfiniLink.entitlements @@ -2,28 +2,10 @@ - aps-environment - development - com.apple.developer.healthkit - - com.apple.developer.healthkit.background-delivery - - com.apple.developer.icloud-container-identifiers - - iCloud.com.alexemry.Infini-iOS - - com.apple.developer.icloud-services - - CloudKit - - com.apple.developer.weatherkit - com.apple.security.app-sandbox com.apple.security.application-groups - - group.com.alexemry.Infini-iOS - + com.apple.security.device.bluetooth com.apple.security.files.user-selected.read-only diff --git a/InfiniLink/Info.plist b/InfiniLink/Info.plist index 95df6d2..d08cd50 100644 --- a/InfiniLink/Info.plist +++ b/InfiniLink/Info.plist @@ -36,10 +36,6 @@ This app uses bluetooth to communicate with your PineTime. NSCalendarsUsageDescription This app needs access to your calendars to receive event reminders on your watch. - NSHealthShareUsageDescription - This app optionally uses Apple Health to write your heart rate and step count data. - NSHealthUpdateUsageDescription - This app optionally uses Apple Health to write your heart rate and step count data. NSLocationAlwaysAndWhenInUseUsageDescription This app needs your location to receive local weather information when the app is in the background. NSLocationAlwaysUsageDescription diff --git a/InfiniLink/Localizable.xcstrings b/InfiniLink/Localizable.xcstrings index c7caea6..4ee9075 100644 --- a/InfiniLink/Localizable.xcstrings +++ b/InfiniLink/Localizable.xcstrings @@ -97,16 +97,6 @@ } } }, - "%@-%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@-%2$@" - } - } - } - }, "%@:%@:%@" : { "localizations" : { "en" : { @@ -175,18 +165,18 @@ } } }, - "%lld-%lld " : { + "%lld-day" : { + + }, + "%lld–%lld " : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$lld-%2$lld " + "value" : "%1$lld–%2$lld " } } } - }, - "%lld-day" : { - }, "%lld%%" : { @@ -819,6 +809,9 @@ } } } + }, + "Max" : { + }, "Maximum" : { "localizations" : { @@ -832,6 +825,9 @@ }, "Metric" : { + }, + "Min" : { + }, "Minimum" : { @@ -869,9 +865,6 @@ }, "No" : { - }, - "No Data" : { - }, "No Logs" : { diff --git a/InfiniLink/Utils/HealthKitManager.swift b/InfiniLink/Utils/HealthKitManager.swift index 7a0e702..9c2e70e 100644 --- a/InfiniLink/Utils/HealthKitManager.swift +++ b/InfiniLink/Utils/HealthKitManager.swift @@ -91,10 +91,10 @@ class HealthKitManager: ObservableObject { guard let healthStore = self.healthStore else { return } - healthStore.requestAuthorization(toShare: [stepsType, heartRateType, caloriesType, workoutType], read: [stepsType, heartRateType]) { success, error in + /*healthStore.requestAuthorization(toShare: [stepsType, heartRateType, caloriesType, workoutType], read: [stepsType, heartRateType]) { success, error in if let error = error { log(error.localizedDescription, caller: "HealthKitManager") } - } + }*/ } } From ddbd2f62fadf97399b824ef0a39f80bc6dca6a7e Mon Sep 17 00:00:00 2001 From: titus Date: Tue, 5 May 2026 08:51:52 +0200 Subject: [PATCH 05/23] revert local files... again --- InfiniLink.xcodeproj/project.pbxproj | 8 +++---- InfiniLink/InfiniLink.entitlements | 20 ++++++++++++++++- InfiniLink/Info.plist | 4 ++++ InfiniLink/Localizable.xcstrings | 29 +++++++++++++++---------- InfiniLink/Utils/HealthKitManager.swift | 4 ++-- 5 files changed, 47 insertions(+), 18 deletions(-) diff --git a/InfiniLink.xcodeproj/project.pbxproj b/InfiniLink.xcodeproj/project.pbxproj index a209b1e..f6dd403 100644 --- a/InfiniLink.xcodeproj/project.pbxproj +++ b/InfiniLink.xcodeproj/project.pbxproj @@ -1031,7 +1031,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"InfiniLink/Preview Content\""; - DEVELOPMENT_TEAM = 6H5DHLRX53; + DEVELOPMENT_TEAM = UXA3H4X42S; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = InfiniLink/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -1041,7 +1041,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.2; - PRODUCT_BUNDLE_IDENTIFIER = com.titusk.InfiniLink; + PRODUCT_BUNDLE_IDENTIFIER = "com.alexemry.Infini-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1063,7 +1063,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"InfiniLink/Preview Content\""; - DEVELOPMENT_TEAM = 6H5DHLRX53; + DEVELOPMENT_TEAM = UXA3H4X42S; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = InfiniLink/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -1073,7 +1073,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.2; - PRODUCT_BUNDLE_IDENTIFIER = com.titusk.InfiniLink; + PRODUCT_BUNDLE_IDENTIFIER = "com.alexemry.Infini-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/InfiniLink/InfiniLink.entitlements b/InfiniLink/InfiniLink.entitlements index cce046c..c5f2828 100644 --- a/InfiniLink/InfiniLink.entitlements +++ b/InfiniLink/InfiniLink.entitlements @@ -2,10 +2,28 @@ + aps-environment + development + com.apple.developer.healthkit + + com.apple.developer.healthkit.background-delivery + + com.apple.developer.icloud-container-identifiers + + iCloud.com.alexemry.Infini-iOS + + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.weatherkit + com.apple.security.app-sandbox com.apple.security.application-groups - + + group.com.alexemry.Infini-iOS + com.apple.security.device.bluetooth com.apple.security.files.user-selected.read-only diff --git a/InfiniLink/Info.plist b/InfiniLink/Info.plist index d08cd50..95df6d2 100644 --- a/InfiniLink/Info.plist +++ b/InfiniLink/Info.plist @@ -36,6 +36,10 @@ This app uses bluetooth to communicate with your PineTime. NSCalendarsUsageDescription This app needs access to your calendars to receive event reminders on your watch. + NSHealthShareUsageDescription + This app optionally uses Apple Health to write your heart rate and step count data. + NSHealthUpdateUsageDescription + This app optionally uses Apple Health to write your heart rate and step count data. NSLocationAlwaysAndWhenInUseUsageDescription This app needs your location to receive local weather information when the app is in the background. NSLocationAlwaysUsageDescription diff --git a/InfiniLink/Localizable.xcstrings b/InfiniLink/Localizable.xcstrings index 4ee9075..c7caea6 100644 --- a/InfiniLink/Localizable.xcstrings +++ b/InfiniLink/Localizable.xcstrings @@ -97,6 +97,16 @@ } } }, + "%@-%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@-%2$@" + } + } + } + }, "%@:%@:%@" : { "localizations" : { "en" : { @@ -165,18 +175,18 @@ } } }, - "%lld-day" : { - - }, - "%lld–%lld " : { + "%lld-%lld " : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$lld–%2$lld " + "value" : "%1$lld-%2$lld " } } } + }, + "%lld-day" : { + }, "%lld%%" : { @@ -809,9 +819,6 @@ } } } - }, - "Max" : { - }, "Maximum" : { "localizations" : { @@ -825,9 +832,6 @@ }, "Metric" : { - }, - "Min" : { - }, "Minimum" : { @@ -865,6 +869,9 @@ }, "No" : { + }, + "No Data" : { + }, "No Logs" : { diff --git a/InfiniLink/Utils/HealthKitManager.swift b/InfiniLink/Utils/HealthKitManager.swift index 9c2e70e..7a0e702 100644 --- a/InfiniLink/Utils/HealthKitManager.swift +++ b/InfiniLink/Utils/HealthKitManager.swift @@ -91,10 +91,10 @@ class HealthKitManager: ObservableObject { guard let healthStore = self.healthStore else { return } - /*healthStore.requestAuthorization(toShare: [stepsType, heartRateType, caloriesType, workoutType], read: [stepsType, heartRateType]) { success, error in + healthStore.requestAuthorization(toShare: [stepsType, heartRateType, caloriesType, workoutType], read: [stepsType, heartRateType]) { success, error in if let error = error { log(error.localizedDescription, caller: "HealthKitManager") } - }*/ + } } } From 8f050d105360a0ae132c8b456ea4998160a97762 Mon Sep 17 00:00:00 2001 From: titus Date: Tue, 5 May 2026 16:21:35 +0200 Subject: [PATCH 06/23] fix padding cropping out left and right most bar --- InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index f3746ea..beaf799 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -152,6 +152,7 @@ struct HeartChartView: View { } } .frame(height: 280) + .padding(.horizontal, 8) .chartYScale(domain: (displayedMin - 20)...(displayedMax + 20)) .chartXScale(domain: windowStart...windowEnd) .chartXAxis { From 016305d9e61a4ab8ba59bed468e82258b566b793 Mon Sep 17 00:00:00 2001 From: titus Date: Wed, 6 May 2026 20:26:52 +0200 Subject: [PATCH 07/23] add native scrollable chart for iOS 17+ users, fallback to chevron style otherwise --- .../Charts/Heart/HeartChartView.swift | 188 ++++++++++++------ 1 file changed, 132 insertions(+), 56 deletions(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index beaf799..61210f9 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -29,6 +29,7 @@ struct HeartChartView: View { @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())!) @@ -40,6 +41,17 @@ struct HeartChartView: View { 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] { let raw = ChartManager.shared.heartPoints() @@ -51,10 +63,16 @@ struct HeartChartView: View { return grouped.map { (bucket, samples) in let values = samples.map { $0.value } return HeartChartDataPoint( - date: bucket, + date: Calendar.current.date(byAdding: .minute, value: 30, to: bucket) ?? bucket, min: values.min() ?? 0, max: values.max() ?? 0, - average: values.reduce(0, +) / Double(values.count), + 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 } @@ -110,6 +128,94 @@ struct HeartChartView: View { displayedMax = Int(windowPoints.map({ $0.max }).max() ?? 0) } + 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 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) + } + } + + @available(iOS 17, *) + var scrollableChart: some View { + Chart { + ForEach(points) { point in + chartContent(for: point) + } + } + .frame(height: 280) + .padding(.horizontal, 8) + .chartYScale(domain: (displayedMin - 20)...(displayedMax + 20)) + .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: (earliestDate - 1800)...(latestDate + 1800)) + .chartScrollPosition(x: $scrollPositionDate) + .chartScrollTargetBehavior(.valueAligned(unit: 3600)) + } + var body: some View { Group { Group { @@ -118,54 +224,10 @@ struct HeartChartView: View { } else { Section { VStack(spacing: 0) { - HStack { - Button { - dayOffset -= 1 - } label: { - Image(systemName: "chevron.left") - } - .disabled(Calendar.current.isDate(windowStart, inSameDayAs: earliestDate)) - - Spacer() - - Text(displayedDate.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day().year())) - .foregroundColor(.primary) - - 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) - - Chart { - ForEach(windowPoints) { point in - chartContent(for: point) - } - } - .frame(height: 280) - .padding(.horizontal, 8) - .chartYScale(domain: (displayedMin - 20)...(displayedMax + 20)) - .chartXScale(domain: windowStart...windowEnd) - .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() - } + if #available(iOS 17, *) { + scrollableChart + } else { + pagedChart } } .buttonStyle(.plain) @@ -178,6 +240,9 @@ struct HeartChartView: View { .font(.system(.title, design: .rounded)) .foregroundColor(.primary) + Text("BPM") + Text(displayedDate.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day().year())) + .foregroundColor(.secondary) + .font(.subheadline) } .fontWeight(.semibold) } @@ -193,20 +258,31 @@ struct HeartChartView: View { } .onAppear { points = heartPoints() + scrollPositionDate = windowStart updateDisplayed() } .onChange(of: dayOffset) { _ in updateDisplayed() } + .onChange(of: scrollPositionDate) { newValue in + displayedDate = newValue + } + .onChange(of: scrollPositionDate) { newValue in + Task { + try? await Task.sleep(nanoseconds: 300_000_000) + if scrollPositionDate == newValue { + let clamped = min(max(newValue, Calendar.current.startOfDay(for: earliestDate)), Calendar.current.startOfDay(for: latestDate)) + if clamped != newValue { + scrollPositionDate = clamped + } + displayedMin = visibleMin + displayedMax = visibleMax + } + } + } .onChange(of: bleManager.heartRate) { _ in points = heartPoints() updateDisplayed() } } } - -#Preview { - List { - HeartChartView() - } -} From e9aea7fe047200ccfdb5cc764c38f5df30c2d16e Mon Sep 17 00:00:00 2001 From: titus Date: Fri, 8 May 2026 04:13:00 +0200 Subject: [PATCH 08/23] fix: correct heart chart Y scale and scroll position on appear and new data --- .../Charts/Heart/HeartChartView.swift | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index 61210f9..42e3489 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -92,6 +92,11 @@ struct HeartChartView: View { point.min == point.max } + func updateYScale() { + displayedMin = visibleMin + displayedMax = visibleMax + } + @ChartContentBuilder func chartContent(for point: HeartChartDataPoint) -> some ChartContent { if isSingleReading(point) { @@ -122,6 +127,7 @@ struct HeartChartView: View { } } + // fixed graph func updateDisplayed() { displayedDate = windowStart displayedMin = Int(windowPoints.map({ $0.min }).min() ?? 0) @@ -258,12 +264,23 @@ struct HeartChartView: View { } .onAppear { points = heartPoints() - scrollPositionDate = windowStart + scrollPositionDate = Date(timeInterval: -86400, since: latestDate) + displayedDate = scrollPositionDate updateDisplayed() } - .onChange(of: dayOffset) { _ in + .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 } @@ -271,17 +288,12 @@ struct HeartChartView: View { Task { try? await Task.sleep(nanoseconds: 300_000_000) if scrollPositionDate == newValue { - let clamped = min(max(newValue, Calendar.current.startOfDay(for: earliestDate)), Calendar.current.startOfDay(for: latestDate)) - if clamped != newValue { - scrollPositionDate = clamped - } - displayedMin = visibleMin - displayedMax = visibleMax + updateYScale() } } } - .onChange(of: bleManager.heartRate) { _ in - points = heartPoints() + // fixed graph + .onChange(of: dayOffset) { _ in updateDisplayed() } } From 74e86e8c52d641302a65d9e7589cc800f48bb7c9 Mon Sep 17 00:00:00 2001 From: titus Date: Fri, 8 May 2026 04:51:52 +0200 Subject: [PATCH 09/23] feat: improve heart chart header to show date range with hour (rounded) and full day detection --- .../Components/Charts/Heart/HeartChartView.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index 42e3489..dc23d86 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -164,6 +164,7 @@ struct HeartChartView: View { } } + // MARK: iOS 16- fixed chart var pagedChart: some View { VStack(spacing: 0) { HStack { @@ -193,6 +194,7 @@ struct HeartChartView: View { } } + // MARK: iOS 17+ scrollable chart @available(iOS 17, *) var scrollableChart: some View { Chart { @@ -246,9 +248,14 @@ struct HeartChartView: View { .font(.system(.title, design: .rounded)) .foregroundColor(.primary) + Text("BPM") - Text(displayedDate.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day().year())) - .foregroundColor(.secondary) - .font(.subheadline) + 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) } From 71756916fdb06faaf6ed954f63daddff3992a5bb Mon Sep 17 00:00:00 2001 From: titus Date: Sat, 9 May 2026 01:52:05 +0200 Subject: [PATCH 10/23] missed one more tiny UX fix --- InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index dc23d86..69d9e8a 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -274,6 +274,7 @@ struct HeartChartView: View { scrollPositionDate = Date(timeInterval: -86400, since: latestDate) displayedDate = scrollPositionDate updateDisplayed() + updateYScale() } .onChange(of: bleManager.heartRate) { _ in let previousLatest = latestDate From 9b4eabffe985a7e7f6ad5250941f1daeb9fcb97e Mon Sep 17 00:00:00 2001 From: titus Date: Tue, 12 May 2026 01:59:24 +0200 Subject: [PATCH 11/23] chart now snaps to full days on bigger swipes chart contains empty space for the rest of the day and always starts on the latest day of the dataset when opening the view removed bottom section (for now) --- .../Charts/Heart/HeartChartView.swift | 71 ++++++++++--------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index 69d9e8a..2441bcc 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -99,32 +99,23 @@ struct HeartChartView: View { @ChartContentBuilder func chartContent(for point: HeartChartDataPoint) -> some ChartContent { - if isSingleReading(point) { - PointMark( - x: .value("Time", point.date), - y: .value("BPM", point.min) - ) - .foregroundStyle(heartColor) - .symbolSize(40) - .symbol(.circle) - } else { - RectangleMark( - x: .value("Time", point.date), - yStart: .value("Min", point.min), - yEnd: .value("Max", point.max), - width: 7 - ) - .foregroundStyle(darkHeartColor) - .clipShape(Capsule()) - - PointMark( - x: .value("Time", point.date), - y: .value("BPM", point.average) - ) - .foregroundStyle(heartColor) - .symbolSize(CGSize(width: 7, height: 7)) - .symbol(.circle) - } + 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 @@ -134,6 +125,7 @@ struct HeartChartView: View { 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) @@ -164,7 +156,6 @@ struct HeartChartView: View { } } - // MARK: iOS 16- fixed chart var pagedChart: some View { VStack(spacing: 0) { HStack { @@ -197,14 +188,20 @@ struct HeartChartView: View { // MARK: iOS 17+ scrollable chart @available(iOS 17, *) var scrollableChart: some View { - Chart { + 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: (displayedMin - 20)...(displayedMax + 20)) + .chartYScale(domain: (yMin...yMax)) .chartXAxis { AxisMarks(values: .stride(by: .hour, count: 6)) { value in AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [4])) @@ -219,9 +216,14 @@ struct HeartChartView: View { } .chartScrollableAxes(.horizontal) .chartXVisibleDomain(length: 86400) - .chartXScale(domain: (earliestDate - 1800)...(latestDate + 1800)) + .chartXScale(domain: (xMin...xMax)) .chartScrollPosition(x: $scrollPositionDate) - .chartScrollTargetBehavior(.valueAligned(unit: 3600)) + .chartScrollTargetBehavior( + .valueAligned( + matching: DateComponents(timeZone: .current, minute: 0, second: 0), + majorAlignment: .matching(DateComponents(timeZone: .current, hour: 0)) + ) + ) } var body: some View { @@ -263,15 +265,17 @@ struct HeartChartView: View { } } .listRowBackground(Color.clear) - if points.count >= 3 { + /* + if points.count >= 3 { Section { Text("Today your heart rate reached a high of \(displayedMax), and dropped to a low of \(displayedMin) BPM.") } } + */ } .onAppear { points = heartPoints() - scrollPositionDate = Date(timeInterval: -86400, since: latestDate) + scrollPositionDate = Calendar.current.startOfDay(for: latestDate) displayedDate = scrollPositionDate updateDisplayed() updateYScale() @@ -306,3 +310,4 @@ struct HeartChartView: View { } } } + From 882d22c0733a5e3f4b399c393dbeec5b8505ceb5 Mon Sep 17 00:00:00 2001 From: Titus <154823939+tituscmd@users.noreply.github.com> Date: Wed, 13 May 2026 19:39:11 +0200 Subject: [PATCH 12/23] Update InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift Co-authored-by: Liam Willey --- InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index 2441bcc..88047db 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -229,7 +229,7 @@ struct HeartChartView: View { var body: some View { Group { Group { - if points.count <= 1 { + if points.flatMap({ $0.values }).count <= 1 { EmptyChartView(.heart) } else { Section { From 8724246d01edcc417fa193edfacc041542acd168 Mon Sep 17 00:00:00 2001 From: titus Date: Tue, 12 May 2026 19:27:29 +0200 Subject: [PATCH 13/23] range bars are selectable and show detailed info in header --- .../Charts/Heart/HeartChartView.swift | 68 ++++++++++++++----- 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index 88047db..1bab588 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -30,7 +30,8 @@ struct HeartChartView: View { @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())!) } @@ -97,6 +98,13 @@ struct HeartChartView: View { displayedMax = visibleMax } + 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) -> some ChartContent { BarMark( @@ -195,6 +203,11 @@ struct HeartChartView: View { let yMax = displayedMax + 20 return Chart { + if let selectedViewHour { + RuleMark(x: .value("Selected Hour", selectedViewHour.date, unit: .hour)) + .foregroundStyle(.secondary) + } + ForEach(points) { point in chartContent(for: point) } @@ -224,6 +237,7 @@ struct HeartChartView: View { majorAlignment: .matching(DateComponents(timeZone: .current, hour: 0)) ) ) + .chartXSelection(value: $rawSelectedHour) } var body: some View { @@ -242,25 +256,45 @@ struct HeartChartView: View { } .buttonStyle(.plain) } header: { - VStack(alignment: .leading) { - Text("Range") - .font(.caption) + 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") + Text("\(rangeFirstHour.formatted(.dateTime.month(.abbreviated).day())), \(rangeFirstHour.formatted(.dateTime.hour()))–\(rangeLastHour.formatted(.dateTime.hour()))") + .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) - 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) + .font(.subheadline) + } + .fontWeight(.semibold) } - .fontWeight(.semibold) } + .listRowInsets(EdgeInsets(top: 18, leading: 0, bottom: 0, trailing: 0)) } } From 8fd9068ec85ef3186a2e9e0ae151c40de2b77220 Mon Sep 17 00:00:00 2001 From: titus Date: Wed, 13 May 2026 16:22:10 +0200 Subject: [PATCH 14/23] feat: add tiny vibration on swiping through bars with slider --- .../Core/Components/Charts/Heart/HeartChartView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index 1bab588..f304679 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -8,7 +8,7 @@ import SwiftUI import Charts -struct HeartChartDataPoint: Identifiable { +struct HeartChartDataPoint: Identifiable, Equatable { var id = UUID() let date: Date let min: Double @@ -342,6 +342,10 @@ struct HeartChartView: View { .onChange(of: dayOffset) { _ in updateDisplayed() } + .onChange(of: selectedViewHour) { newValue in + guard newValue != nil else { return } + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } } } From b0ec58cb95e303320b575f894666fb516eec5629 Mon Sep 17 00:00:00 2001 From: titus Date: Wed, 13 May 2026 18:35:38 +0200 Subject: [PATCH 15/23] finalize slider for scrollable graph --- .../Charts/Heart/HeartChartView.swift | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index f304679..254be72 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -86,8 +86,8 @@ struct HeartChartView: View { 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) + 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 @@ -197,16 +197,15 @@ struct HeartChartView: View { @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 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(.secondary) - } + RuleMark(x: .value("Selected Hour", selectedViewHour.date, unit: .hour)) + .foregroundStyle(Color.gray) + } ForEach(points) { point in chartContent(for: point) @@ -268,7 +267,9 @@ struct HeartChartView: View { .font(.system(.title, design: .rounded)) .foregroundColor(.primary) + Text("BPM") - Text("\(rangeFirstHour.formatted(.dateTime.month(.abbreviated).day())), \(rangeFirstHour.formatted(.dateTime.hour()))–\(rangeLastHour.formatted(.dateTime.hour()))") + + 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") · \(Int(selectedViewHour.average)) BPM avg") .foregroundColor(.secondary) .font(.subheadline) } @@ -299,13 +300,6 @@ struct HeartChartView: View { } } .listRowBackground(Color.clear) - /* - if points.count >= 3 { - Section { - Text("Today your heart rate reached a high of \(displayedMax), and dropped to a low of \(displayedMin) BPM.") - } - } - */ } .onAppear { points = heartPoints() From 311d7f8671c30ac43982f4d8718b8bcd33c76b8e Mon Sep 17 00:00:00 2001 From: titus Date: Wed, 13 May 2026 19:36:48 +0200 Subject: [PATCH 16/23] finalize slider to work on both charts and make it prettier --- .../Charts/Heart/HeartChartView.swift | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index 254be72..bca7524 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -106,7 +106,7 @@ struct HeartChartView: View { } @ChartContentBuilder - func chartContent(for point: HeartChartDataPoint) -> some ChartContent { + func chartContent(for point: HeartChartDataPoint, selected: HeartChartDataPoint?) -> some ChartContent { BarMark( x: .value("Time", point.date), yStart: .value("Min", point.min), @@ -115,7 +115,7 @@ struct HeartChartView: View { ) .foregroundStyle(darkHeartColor) .cornerRadius(4) - //.clipShape(Capsule()) + .opacity(selected == nil || selected?.date == point.date ? 1 : 0.5) PointMark( x: .value("Time", point.date), @@ -124,6 +124,7 @@ struct HeartChartView: View { .foregroundStyle(heartColor) .symbolSize(CGSize(width: 7, height: 7)) .symbol(.circle) + .opacity(selected == nil || selected?.date == point.date ? 1 : 0.25) } // fixed graph @@ -142,8 +143,13 @@ struct HeartChartView: View { let pageMax = Int(pagePoints.map({ $0.max }).max() ?? 0) return Chart { + if let selectedViewHour { + RuleMark(x: .value("Selected Hour", selectedViewHour.date, unit: .hour)) + .foregroundStyle(Color.gray) + } + ForEach(pagePoints) { point in - chartContent(for: point) + chartContent(for: point, selected: selectedViewHour) } } .frame(height: 280) @@ -162,6 +168,26 @@ struct HeartChartView: View { AxisValueLabel() } } + .overlay( + GeometryReader { geo in + Color.clear + .contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance: 0) + .onChanged { value in + let leadingPadding: CGFloat = 8 + let yAxisWidth: CGFloat = 40 + let adjustedWidth = geo.size.width - yAxisWidth - leadingPadding + let clampedX = min(max(value.location.x - leadingPadding, 0), adjustedWidth) + let fraction = clampedX / adjustedWidth + let totalSeconds: TimeInterval = 86400 + rawSelectedHour = start.addingTimeInterval(fraction * totalSeconds) + } + .onEnded { _ in + rawSelectedHour = nil + } + ) + } + ) } var pagedChart: some View { @@ -208,7 +234,7 @@ struct HeartChartView: View { } ForEach(points) { point in - chartContent(for: point) + chartContent(for: point, selected: selectedViewHour) } } .frame(height: 280) @@ -310,12 +336,10 @@ struct HeartChartView: View { } .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) - } + if !Calendar.current.isDate(latestDate, inSameDayAs: previousLatest) { + dayOffset = 0 + scrollPositionDate = Calendar.current.startOfDay(for: latestDate) } updateDisplayed() updateYScale() // scrollable chart From 74b1f5c1e050abd5ac5231687bf9a81121f56c8b Mon Sep 17 00:00:00 2001 From: titus Date: Wed, 13 May 2026 20:13:30 +0200 Subject: [PATCH 17/23] remove some redundant computations and banner above heart rate graph and change --- .../Charts/Heart/HeartChartView.swift | 33 ++++++++----------- InfiniLink/Core/HeartView.swift | 18 ---------- 2 files changed, 14 insertions(+), 37 deletions(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index bca7524..a1d8781 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -13,7 +13,7 @@ struct HeartChartDataPoint: Identifiable, Equatable { let date: Date let min: Double let max: Double - let average: Double + let median: Double let values: [Double] } @@ -67,7 +67,7 @@ struct HeartChartView: View { date: Calendar.current.date(byAdding: .minute, value: 30, to: bucket) ?? bucket, min: values.min() ?? 0, max: values.max() ?? 0, - average: { + median: { let sorted = values.sorted() let mid = sorted.count / 2 return sorted.count % 2 == 0 @@ -119,7 +119,7 @@ struct HeartChartView: View { PointMark( x: .value("Time", point.date), - y: .value("BPM", point.average) + y: .value("BPM", point.median) ) .foregroundStyle(heartColor) .symbolSize(CGSize(width: 7, height: 7)) @@ -136,11 +136,10 @@ struct HeartChartView: View { // 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) + 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 { @@ -148,14 +147,14 @@ struct HeartChartView: View { .foregroundStyle(Color.gray) } - ForEach(pagePoints) { point in + ForEach(windowPoints) { point in chartContent(for: point, selected: selectedViewHour) } } .frame(height: 280) .padding(.horizontal, 8) - .chartYScale(domain: (pageMin - 20)...(pageMax + 20)) - .chartXScale(domain: start...end) + .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])) @@ -174,13 +173,9 @@ struct HeartChartView: View { .contentShape(Rectangle()) .gesture(DragGesture(minimumDistance: 0) .onChanged { value in - let leadingPadding: CGFloat = 8 - let yAxisWidth: CGFloat = 40 - let adjustedWidth = geo.size.width - yAxisWidth - leadingPadding - let clampedX = min(max(value.location.x - leadingPadding, 0), adjustedWidth) - let fraction = clampedX / adjustedWidth - let totalSeconds: TimeInterval = 86400 - rawSelectedHour = start.addingTimeInterval(fraction * totalSeconds) + 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 @@ -295,7 +290,7 @@ struct HeartChartView: View { + 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") · \(Int(selectedViewHour.average)) BPM avg") + 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) } diff --git a/InfiniLink/Core/HeartView.swift b/InfiniLink/Core/HeartView.swift index cc1f2ea..6521dff 100644 --- a/InfiniLink/Core/HeartView.swift +++ b/InfiniLink/Core/HeartView.swift @@ -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)) From d3379f810d89f80e42438064af0227e811f20cc3 Mon Sep 17 00:00:00 2001 From: titus Date: Wed, 13 May 2026 20:15:28 +0200 Subject: [PATCH 18/23] remove now outdated line from readme --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 2752581..f48b610 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,6 @@ - Download and install InfiniTime firmware updates from releases and GitHub Actions using the GitHub API (local file updates are supported) ### 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: From f599c581a5e31cb45b9c169282c0e97f42d77e36 Mon Sep 17 00:00:00 2001 From: Liam Willey Date: Thu, 14 May 2026 14:40:09 -0400 Subject: [PATCH 19/23] Clean up --- .../Charts/Heart/HeartChartView.swift | 345 +++++++----------- InfiniLink/Localizable.xcstrings | 36 +- InfiniLink/Utils/ChartManager.swift | 2 +- 3 files changed, 140 insertions(+), 243 deletions(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index a1d8781..3c22534 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -8,6 +8,9 @@ import SwiftUI import Charts +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 @@ -25,46 +28,47 @@ 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 } - } + private let cal = Calendar.current 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 earliestDate: Date { + points.map({ $0.date }).min() ?? Date() } - var visibleMax: Int { - Int(visiblePoints.map({ $0.max }).max() ?? 0) + var latestDate: Date { + points.map({ $0.date }).max() ?? Date() + } + var displayedMin: Int { + Int(visiblePoints.map({ $0.min }).min() ?? 50) + } + var displayedMax: Int { + Int(visiblePoints.map({ $0.max }).max() ?? 100) + } + var selectedViewHour: HeartChartDataPoint? { + guard let rawSelectedHour else { return nil } + return points.first { + cal.isDate(rawSelectedHour, equalTo: $0.date, toGranularity: .hour) + } } func heartPoints() -> [HeartChartDataPoint] { 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() + let comps = cal.dateComponents([.year, .month, .day, .hour], from: sample.timestamp ?? Date()) + return cal.date(from: comps) ?? Date() } - return grouped.map { (bucket, samples) in + 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, + date: cal.date(byAdding: .minute, value: 30, to: bucket) ?? bucket, min: values.min() ?? 0, max: values.max() ?? 0, median: { @@ -79,32 +83,10 @@ struct HeartChartView: View { }.sorted { $0.date < $1.date } } - var earliestDate: Date { - points.map({ $0.date }).min() ?? Date() - } - var latestDate: 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 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( @@ -127,163 +109,107 @@ struct HeartChartView: View { .opacity(selected == nil || selected?.date == point.date ? 1 : 0.25) } - // fixed graph - func updateDisplayed() { - displayedDate = windowStart - displayedMin = Int(windowPoints.map({ $0.min }).min() ?? 0) - displayedMax = Int(windowPoints.map({ $0.max }).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) } - // 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) + func chart() -> some View { + let xMin = cal.startOfDay(for: earliestDate) + let xMax = cal.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(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 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) + } } - ) - } - - var pagedChart: some View { - VStack(spacing: 0) { - HStack { - Button { - dayOffset -= 1 - } label: { - Image(systemName: "chevron.left") + .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))) } - .disabled(Calendar.current.isDate(windowStart, inSameDayAs: earliestDate)) - - Spacer() - - Button { - dayOffset += 1 - } label: { - Image(systemName: "chevron.right") + } + .chartYAxis { + AxisMarks(position: .trailing) { value in + AxisGridLine() + AxisValueLabel() } - .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) + return Group { + if #available(iOS 17, *) { + chart + .chartScrollableAxes(.horizontal) + .chartXVisibleDomain(length: 86400) + .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 // 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 + } + ) } - - 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.flatMap({ $0.values }).count <= 1 { - EmptyChartView(.heart) - } else { - Section { - VStack(spacing: 0) { - if #available(iOS 17, *) { - scrollableChart - } else { - pagedChart - } - } - .buttonStyle(.plain) - } header: { - 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) + if points.flatMap({ $0.values }).count <= 1 { + EmptyChartView(.heart) + } else { + Section { + chart() + } header: { + HStack { + VStack(alignment: .leading) { + 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) @@ -293,67 +219,43 @@ struct HeartChartView: View { 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) + } 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 = Calendar.current.component(.hour, from: rounded) == 0 + 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()))") + ? 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) + if #unavailable(iOS 17), selectedViewHour == nil { + Spacer() + scrollButton(-1, disabled: cal.startOfDay(for: latestDate) <= cal.startOfDay(for: Date())) + scrollButton(1, disabled: cal.startOfDay(for: earliestDate) >= cal.startOfDay(for: Date())) } } - - .listRowInsets(EdgeInsets(top: 18, leading: 0, bottom: 0, trailing: 0)) } + .listRowInsets(EdgeInsets(top: 18, leading: 0, bottom: 0, trailing: 0)) } - .listRowBackground(Color.clear) } + .listRowBackground(Color.clear) .onAppear { points = heartPoints() - scrollPositionDate = Calendar.current.startOfDay(for: latestDate) - displayedDate = scrollPositionDate - updateDisplayed() - updateYScale() + scrollPositionDate = cal.startOfDay(for: latestDate) } .onChange(of: bleManager.heartRate) { _ in - let previousLatest = latestDate points = heartPoints() - if !Calendar.current.isDate(latestDate, inSameDayAs: previousLatest) { - dayOffset = 0 + let previousLatest = latestDate + if !cal.isDate(latestDate, inSameDayAs: previousLatest) { scrollPositionDate = Calendar.current.startOfDay(for: 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) - if scrollPositionDate == newValue { - updateYScale() - } - } - } - // fixed graph - .onChange(of: dayOffset) { _ in - updateDisplayed() } .onChange(of: selectedViewHour) { newValue in guard newValue != nil else { return } @@ -361,4 +263,3 @@ struct HeartChartView: View { } } } - diff --git a/InfiniLink/Localizable.xcstrings b/InfiniLink/Localizable.xcstrings index c7caea6..9b363fd 100644 --- a/InfiniLink/Localizable.xcstrings +++ b/InfiniLink/Localizable.xcstrings @@ -97,12 +97,12 @@ } } }, - "%@-%@" : { + "%@, %@–%@ · %lld %@%@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$@-%2$@" + "value" : "%1$@, %2$@–%3$@ · %4$lld %5$@%6$@" } } } @@ -175,18 +175,18 @@ } } }, - "%lld-%lld " : { + "%lld-day" : { + + }, + "%lld–%lld " : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "%1$lld-%2$lld " + "value" : "%1$lld–%2$lld " } } } - }, - "%lld-day" : { - }, "%lld%%" : { @@ -819,6 +819,9 @@ } } } + }, + "Max" : { + }, "Maximum" : { "localizations" : { @@ -832,6 +835,9 @@ }, "Metric" : { + }, + "Min" : { + }, "Minimum" : { @@ -869,9 +875,6 @@ }, "No" : { - }, - "No Data" : { - }, "No Logs" : { @@ -980,6 +983,9 @@ }, "Search for an address..." : { + }, + "Selected Hour" : { + }, "Send" : { @@ -1196,16 +1202,6 @@ }, "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" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Today your heart rate reached a high of %1$lld, and dropped to a low of %2$lld BPM." - } - } - } }, "Total" : { diff --git a/InfiniLink/Utils/ChartManager.swift b/InfiniLink/Utils/ChartManager.swift index b806da7..f635bae 100644 --- a/InfiniLink/Utils/ChartManager.swift +++ b/InfiniLink/Utils/ChartManager.swift @@ -108,7 +108,7 @@ class ChartManager: ObservableObject { func heartPoints(predicate: NSPredicate? = nil) -> [HeartDataPoint] { let fetchRequest: NSFetchRequest = HeartDataPoint.fetchRequest() - fetchRequest.predicate = predicate ?? weekPredicate + fetchRequest.predicate = predicate ?? dayPredicate do { return try persistenceController.container.viewContext.fetch(fetchRequest) From 718ffb64cb01d444125b600f42b08cd3ad8b05cf Mon Sep 17 00:00:00 2001 From: titus Date: Thu, 14 May 2026 23:31:28 +0200 Subject: [PATCH 20/23] fix some stuff broken by cleanup commit --- .../Charts/Heart/HeartChartView.swift | 27 ++++++++++++------- .../Core/Settings/HeartSettingsView.swift | 8 ++++++ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index 3c22534..bba92a1 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -16,6 +16,7 @@ struct HeartChartDataPoint: Identifiable, Equatable { let date: Date let min: Double let max: Double + let average: Double let median: Double let values: [Double] } @@ -26,6 +27,7 @@ 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 displayedDate: Date = Date() @@ -56,6 +58,12 @@ struct HeartChartView: View { cal.isDate(rawSelectedHour, equalTo: $0.date, toGranularity: .hour) } } + var pointMarkLabel: String { + heartPointMarkMode == "average" ? "avg" : "mdn" + } + func pointMarkValue(for point: HeartChartDataPoint) -> Double { + heartPointMarkMode == "average" ? point.average : point.median + } func heartPoints() -> [HeartChartDataPoint] { let raw = ChartManager.shared.heartPoints() @@ -71,12 +79,13 @@ struct HeartChartView: View { 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] + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid] }(), values: values ) @@ -101,7 +110,7 @@ struct HeartChartView: View { PointMark( x: .value("Time", point.date), - y: .value("BPM", point.median) + y: .value("BPM", pointMarkValue(for: point)) ) .foregroundStyle(heartColor) .symbolSize(CGSize(width: 7, height: 7)) @@ -126,7 +135,7 @@ struct HeartChartView: View { func chart() -> some View { let xMin = cal.startOfDay(for: earliestDate) - let xMax = cal.startOfDay(for: latestDate) + 86400 + 3600 // + one day and an hour, fixes the snappy scrolling otherwise breaking sometimes + let xMax = cal.startOfDay(for: latestDate) + 86400 + 3600 let yMin = displayedMin - 20 let yMax = displayedMax + 20 @@ -179,7 +188,7 @@ struct HeartChartView: View { .contentShape(Rectangle()) .gesture(DragGesture(minimumDistance: 0) .onChanged { value in - let adjustedWidth = geo.size.width - 48 // 40 y-axis + 8 horizontal padding + let adjustedWidth = geo.size.width - 48 let normalizedXPosition = min(max(value.location.x - 8, 0), adjustedWidth) / adjustedWidth rawSelectedHour = xMin.addingTimeInterval(normalizedXPosition * 86400) } @@ -216,7 +225,7 @@ struct HeartChartView: View { + 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" : "")") + 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 { @@ -237,8 +246,8 @@ struct HeartChartView: View { .fontWeight(.semibold) if #unavailable(iOS 17), selectedViewHour == nil { Spacer() - scrollButton(-1, disabled: cal.startOfDay(for: latestDate) <= cal.startOfDay(for: Date())) - scrollButton(1, disabled: cal.startOfDay(for: earliestDate) >= cal.startOfDay(for: Date())) + scrollButton(-1, disabled: cal.startOfDay(for: scrollPositionDate) <= cal.startOfDay(for: earliestDate)) + scrollButton(1, disabled: cal.startOfDay(for: scrollPositionDate) >= cal.startOfDay(for: latestDate)) } } } @@ -251,8 +260,8 @@ struct HeartChartView: View { scrollPositionDate = cal.startOfDay(for: latestDate) } .onChange(of: bleManager.heartRate) { _ in - points = heartPoints() let previousLatest = latestDate + points = heartPoints() if !cal.isDate(latestDate, inSameDayAs: previousLatest) { scrollPositionDate = Calendar.current.startOfDay(for: latestDate) } 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: { From 81dcd935ab2b13eedd210acd39e7285a66c5d9d2 Mon Sep 17 00:00:00 2001 From: titus Date: Fri, 15 May 2026 00:11:56 +0200 Subject: [PATCH 21/23] apply review changes and tweak colors --- .../Charts/Heart/HeartChartView.swift | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index bba92a1..d37ac77 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -33,6 +33,8 @@ struct HeartChartView: View { @State private var displayedDate: Date = Date() @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 @@ -46,12 +48,6 @@ struct HeartChartView: View { var latestDate: Date { points.map({ $0.date }).max() ?? Date() } - var displayedMin: Int { - Int(visiblePoints.map({ $0.min }).min() ?? 50) - } - var displayedMax: Int { - Int(visiblePoints.map({ $0.max }).max() ?? 100) - } var selectedViewHour: HeartChartDataPoint? { guard let rawSelectedHour else { return nil } return points.first { @@ -59,12 +55,17 @@ struct HeartChartView: View { } } var pointMarkLabel: String { - heartPointMarkMode == "average" ? "avg" : "mdn" + 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 heartPoints() -> [HeartChartDataPoint] { let raw = ChartManager.shared.heartPoints() @@ -106,7 +107,7 @@ struct HeartChartView: View { ) .foregroundStyle(darkHeartColor) .cornerRadius(4) - .opacity(selected == nil || selected?.date == point.date ? 1 : 0.5) + .opacity(selected == nil || selected?.date == point.date ? 1 : 0.35) PointMark( x: .value("Time", point.date), @@ -115,7 +116,7 @@ struct HeartChartView: View { .foregroundStyle(heartColor) .symbolSize(CGSize(width: 7, height: 7)) .symbol(.circle) - .opacity(selected == nil || selected?.date == point.date ? 1 : 0.25) + .opacity(selected == nil || selected?.date == point.date ? 1 : 0.1) } func scrollButton(_ dir: Int, disabled: Bool) -> some View { @@ -256,17 +257,27 @@ struct HeartChartView: View { } .listRowBackground(Color.clear) .onAppear { - points = heartPoints() - scrollPositionDate = cal.startOfDay(for: latestDate) + if points.isEmpty { + points = heartPoints() + scrollPositionDate = cal.startOfDay(for: latestDate) + updateYScale() + } + } + .onChange(of: scrollPositionDate) { newValue in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + if scrollPositionDate == newValue { + updateYScale() + } + } } .onChange(of: bleManager.heartRate) { _ in let previousLatest = latestDate points = heartPoints() if !cal.isDate(latestDate, inSameDayAs: previousLatest) { - scrollPositionDate = Calendar.current.startOfDay(for: latestDate) + scrollPositionDate = cal.startOfDay(for: latestDate) } } - .onChange(of: selectedViewHour) { newValue in + .onChange(of: selectedViewHour?.date) { newValue in guard newValue != nil else { return } UIImpactFeedbackGenerator(style: .light).impactOccurred() } From 17302ce7cbbea5e97d1ec823555b1703597fe442 Mon Sep 17 00:00:00 2001 From: Liam Willey Date: Thu, 14 May 2026 18:33:41 -0400 Subject: [PATCH 22/23] First iteration of dynamic fetch --- InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index d37ac77..24b91b3 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -30,7 +30,6 @@ struct HeartChartView: View { @AppStorage("heartPointMarkMode") private var heartPointMarkMode = "average" @State private var points = [HeartChartDataPoint]() - @State private var displayedDate: Date = Date() @State private var scrollPositionDate: Date = Date() @State private var rawSelectedHour: Date? = nil @State private var displayedMin: Int = 40 @@ -67,7 +66,8 @@ struct HeartChartView: View { } func heartPoints() -> [HeartChartDataPoint] { - let raw = ChartManager.shared.heartPoints() + let predicate = NSPredicate(format: "deviceId == %@ AND timestamp >= %@ AND timestamp <= %@", bleManager.pairedDeviceID!, cal.startOfDay(for: earliestDate) as NSDate, latestDate as NSDate) + let raw = ChartManager.shared.heartPoints(predicate: predicate) let grouped = Dictionary(grouping: raw) { sample -> Date in let comps = cal.dateComponents([.year, .month, .day, .hour], from: sample.timestamp ?? Date()) @@ -264,6 +264,7 @@ struct HeartChartView: View { } } .onChange(of: scrollPositionDate) { newValue in + points = heartPoints() DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { if scrollPositionDate == newValue { updateYScale() From 35b4cbde81461649299ccfa135cd23ec6ac74044 Mon Sep 17 00:00:00 2001 From: Liam Willey Date: Fri, 15 May 2026 09:02:38 -0400 Subject: [PATCH 23/23] Next iteration of improved data fetch --- .../Charts/Heart/HeartChartView.swift | 79 ++++++++++++------- InfiniLink/Localizable.xcstrings | 17 +++- 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift index 24b91b3..80316e6 100644 --- a/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift +++ b/InfiniLink/Core/Components/Charts/Heart/HeartChartView.swift @@ -30,12 +30,14 @@ struct HeartChartView: View { @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 var visiblePoints: [HeartChartDataPoint] { let visibleEnd = Date(timeInterval: 86400, since: scrollPositionDate) @@ -65,32 +67,49 @@ struct HeartChartView: View { displayedMax = Int(visiblePoints.map({ $0.max }).max() ?? 220) } - func heartPoints() -> [HeartChartDataPoint] { - let predicate = NSPredicate(format: "deviceId == %@ AND timestamp >= %@ AND timestamp <= %@", bleManager.pairedDeviceID!, cal.startOfDay(for: earliestDate) as NSDate, latestDate as NSDate) + 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 { $0.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 } + 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 { @@ -172,7 +191,7 @@ struct HeartChartView: View { if #available(iOS 17, *) { chart .chartScrollableAxes(.horizontal) - .chartXVisibleDomain(length: 86400) + .chartXVisibleDomain(length: visibleDomain) .chartScrollPosition(x: $scrollPositionDate) .chartScrollTargetBehavior( .valueAligned( @@ -257,14 +276,20 @@ struct HeartChartView: View { } .listRowBackground(Color.clear) .onAppear { - if points.isEmpty { - points = heartPoints() - scrollPositionDate = cal.startOfDay(for: latestDate) - updateYScale() - } + fetchPoints(around: Date()) + scrollPositionDate = cal.startOfDay(for: latestDate) + updateYScale() } .onChange(of: scrollPositionDate) { newValue in - points = heartPoints() + 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() @@ -273,7 +298,7 @@ struct HeartChartView: View { } .onChange(of: bleManager.heartRate) { _ in let previousLatest = latestDate - points = heartPoints() + fetchPoints(around: Date()) if !cal.isDate(latestDate, inSameDayAs: previousLatest) { scrollPositionDate = cal.startOfDay(for: latestDate) } diff --git a/InfiniLink/Localizable.xcstrings b/InfiniLink/Localizable.xcstrings index 631f42f..4fc4183 100644 --- a/InfiniLink/Localizable.xcstrings +++ b/InfiniLink/Localizable.xcstrings @@ -272,6 +272,9 @@ } } } + }, + "avg" : { + }, "Back" : { @@ -311,6 +314,9 @@ }, "Checking for updates..." : { + }, + "Choose how the point mark on the heart rate chart is calculated." : { + }, "Clear All Exercises" : { @@ -832,6 +838,12 @@ } } } + }, + "mdn" : { + + }, + "Median" : { + }, "Metric" : { @@ -932,6 +944,9 @@ }, "Pinned" : { + }, + "Point Mark" : { + }, "Poor" : { @@ -1439,4 +1454,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +}