From c9b24981225bda9ccd3af8531ee985d98848728f Mon Sep 17 00:00:00 2001 From: titus Date: Mon, 4 May 2026 20:57:10 +0200 Subject: [PATCH 01/11] 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 0c98bbc..8dfd8f9 100644 --- a/InfiniLink.xcodeproj/project.pbxproj +++ b/InfiniLink.xcodeproj/project.pbxproj @@ -1027,17 +1027,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"; @@ -1059,17 +1059,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 e1762db..daa3bd6 100644 --- a/InfiniLink/Localizable.xcstrings +++ b/InfiniLink/Localizable.xcstrings @@ -96,16 +96,6 @@ } } }, - "%@-%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@-%2$@" - } - } - } - }, "%@:%@:%@" : { "localizations" : { "en" : { @@ -174,18 +164,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%%" : { @@ -805,6 +795,9 @@ } } } + }, + "Max" : { + }, "Maximum" : { "localizations" : { @@ -818,6 +811,9 @@ }, "Metric" : { + }, + "Min" : { + }, "Minimum" : { @@ -855,9 +851,6 @@ }, "No" : { - }, - "No Data" : { - }, "No Logs" : { diff --git a/InfiniLink/Utils/ChartManager.swift b/InfiniLink/Utils/ChartManager.swift index e3bf5cd..9205acd 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 4b59b76523b8d2da39bb85c3bd211738640ca721 Mon Sep 17 00:00:00 2001 From: titus Date: Tue, 5 May 2026 05:57:44 +0200 Subject: [PATCH 02/11] 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 5c73981552f41149da6b90ffea4de949e6a68465 Mon Sep 17 00:00:00 2001 From: titus Date: Tue, 5 May 2026 06:17:16 +0200 Subject: [PATCH 03/11] 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 8dfd8f9..0c98bbc 100644 --- a/InfiniLink.xcodeproj/project.pbxproj +++ b/InfiniLink.xcodeproj/project.pbxproj @@ -1027,17 +1027,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"; @@ -1059,17 +1059,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 daa3bd6..e1762db 100644 --- a/InfiniLink/Localizable.xcstrings +++ b/InfiniLink/Localizable.xcstrings @@ -96,6 +96,16 @@ } } }, + "%@-%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@-%2$@" + } + } + } + }, "%@:%@:%@" : { "localizations" : { "en" : { @@ -164,18 +174,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%%" : { @@ -795,9 +805,6 @@ } } } - }, - "Max" : { - }, "Maximum" : { "localizations" : { @@ -811,9 +818,6 @@ }, "Metric" : { - }, - "Min" : { - }, "Minimum" : { @@ -851,6 +855,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 27cac657276bc1cf317be29679b955b9077dcd04 Mon Sep 17 00:00:00 2001 From: titus Date: Tue, 5 May 2026 08:49:34 +0200 Subject: [PATCH 04/11] 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 0c98bbc..1f0b3cd 100644 --- a/InfiniLink.xcodeproj/project.pbxproj +++ b/InfiniLink.xcodeproj/project.pbxproj @@ -1027,7 +1027,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"; @@ -1037,7 +1037,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"; @@ -1059,7 +1059,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"; @@ -1069,7 +1069,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 e1762db..daa3bd6 100644 --- a/InfiniLink/Localizable.xcstrings +++ b/InfiniLink/Localizable.xcstrings @@ -96,16 +96,6 @@ } } }, - "%@-%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@-%2$@" - } - } - } - }, "%@:%@:%@" : { "localizations" : { "en" : { @@ -174,18 +164,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%%" : { @@ -805,6 +795,9 @@ } } } + }, + "Max" : { + }, "Maximum" : { "localizations" : { @@ -818,6 +811,9 @@ }, "Metric" : { + }, + "Min" : { + }, "Minimum" : { @@ -855,9 +851,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 ca4fe3630382d5a996d9397a574124ce9c06d4c8 Mon Sep 17 00:00:00 2001 From: titus Date: Tue, 5 May 2026 08:51:52 +0200 Subject: [PATCH 05/11] 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 1f0b3cd..0c98bbc 100644 --- a/InfiniLink.xcodeproj/project.pbxproj +++ b/InfiniLink.xcodeproj/project.pbxproj @@ -1027,7 +1027,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"; @@ -1037,7 +1037,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"; @@ -1059,7 +1059,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"; @@ -1069,7 +1069,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 daa3bd6..e1762db 100644 --- a/InfiniLink/Localizable.xcstrings +++ b/InfiniLink/Localizable.xcstrings @@ -96,6 +96,16 @@ } } }, + "%@-%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@-%2$@" + } + } + } + }, "%@:%@:%@" : { "localizations" : { "en" : { @@ -164,18 +174,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%%" : { @@ -795,9 +805,6 @@ } } } - }, - "Max" : { - }, "Maximum" : { "localizations" : { @@ -811,9 +818,6 @@ }, "Metric" : { - }, - "Min" : { - }, "Minimum" : { @@ -851,6 +855,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 0dd7b0957ec0c8b1e3e59e804330a372ff4bd234 Mon Sep 17 00:00:00 2001 From: titus Date: Tue, 5 May 2026 16:21:35 +0200 Subject: [PATCH 06/11] 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 93d9bbc99a84a5aa7e72b6215f8d17c38a88d22d Mon Sep 17 00:00:00 2001 From: titus Date: Wed, 6 May 2026 20:26:52 +0200 Subject: [PATCH 07/11] 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 15eb620a3f4b2324eb056b0983f60fa8cca5ae9d Mon Sep 17 00:00:00 2001 From: titus Date: Fri, 8 May 2026 04:13:00 +0200 Subject: [PATCH 08/11] 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 6ec3cdfe3f9a1421e588325b56bd07dd46ecf05b Mon Sep 17 00:00:00 2001 From: titus Date: Fri, 8 May 2026 04:51:52 +0200 Subject: [PATCH 09/11] 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 d38f458845e55d339fb18ee0642b28dbc33a91ca Mon Sep 17 00:00:00 2001 From: titus Date: Sat, 9 May 2026 01:52:05 +0200 Subject: [PATCH 10/11] 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 2cb635d6cca78555905c76ec364c8d5efbb1e0cb Mon Sep 17 00:00:00 2001 From: titus Date: Tue, 12 May 2026 01:59:24 +0200 Subject: [PATCH 11/11] 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 { } } } +