Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API/public #24

Open
wants to merge 10 commits into
base: HealthChart/main
Choose a base branch
from
13 changes: 12 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ let package = Package(
.iOS(.v17)
],
products: [
.library(name: "SpeziHealthKit", targets: ["SpeziHealthKit"])
.library(name: "SpeziHealthKit", targets: ["SpeziHealthKit", "SpeziHealthCharts"])
],
dependencies: [
.package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.7.0")
Expand All @@ -43,6 +43,17 @@ let package = Package(
],
plugins: [] + swiftLintPlugin()
),
.target(
name: "SpeziHealthCharts",
dependencies: [
.product(name: "Spezi", package: "Spezi")
],
swiftSettings: [
swiftConcurrency,
.enableUpcomingFeature("InferSendableFromCaptures")
nriedman marked this conversation as resolved.
Show resolved Hide resolved
],
plugins: [] + swiftLintPlugin()
),
.testTarget(
name: "SpeziHealthKitTests",
dependencies: [
Expand Down
60 changes: 60 additions & 0 deletions Sources/SpeziHealthCharts/MetricChart/ChartViews/HealthChart.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import SwiftUI
import HealthKit


public struct HealthChart: View {
@State private var privateRange: ChartRange
private var privateRangeBinding: Binding<ChartRange>?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this works in the end, as this will not be used for view re-render by SwiftUI. Not sure if it is needed though. Just a little note.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @Supereg! Would you mind explaining a little more about this? When I tested the UI with this setup, I saw that when I change the date range in the lowest view in the hierarchy, this binding propagated that change up to the top level as expected, and vice versa when I change the range at the highest level. That is, the views seem to re-render correctly when I change the state? Let me know if I'm misunderstanding what you mean!


var range: Binding<ChartRange> {
Binding(
get: {
privateRangeBinding?.wrappedValue ?? privateRange
}, set: { newValue in
if let privateRangeBinding {
privateRangeBinding.wrappedValue = newValue
} else {
privateRange = newValue
}
}
)
}

private let quantityType: HKQuantityType
private let dataProvider: any DataProvider


public var body: some View {
InternalHealthChart(quantityType, range: range, provider: dataProvider)
}


public init(
_ type: HKQuantityType,
in initialRange: ChartRange = .month,
provider: any DataProvider = HealthKitDataProvider()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What was the reason to have the concept of the DataProvider be part of the public interface here? Would it make sense to just to just pass a collection of (HK) samples to the chart to be as reusable as possible. For example, if I just have a few recordings form my Bluetooth device that I turned into HKQuantitySamples, I can just pass the collection to the Chart.
I think the concept of the HealthKitDataProvider API is great, especially to make it easier to query samples from the HealthKitStore. But can these two APIs be separate? Or have a chart view that works with a DataProvider be a separate component that sits on top of a simple chart implementation that just takes a collection of samples?
Let me know what your thoughts are here 👍

) {
self.quantityType = type
self.privateRange = initialRange
self.dataProvider = provider
}

public init(
_ type: HKQuantityType,
range: Binding<ChartRange>,
provider: any DataProvider = HealthKitDataProvider()
) {
self.privateRange = range.wrappedValue
self.privateRangeBinding = range
self.quantityType = type
self.dataProvider = provider
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import HealthKit
import SwiftUI


struct InternalHealthChart: View {
@Binding private var range: ChartRange
@State private var measurements: [Int] = [1]


@Environment(\.disabledChartInteractions) private var disabledInteractions
@Environment(\.healthChartStyle) private var chartStyle


private let quantityType: HKQuantityType
private let dataProvider: any DataProvider


var body: some View {
List {
Picker("Internal Chart Range", selection: $range) {
Text("Daily").tag(ChartRange.day)
Text("Weekly").tag(ChartRange.week)
Text("Monthly").tag(ChartRange.month)
Text("Six Months").tag(ChartRange.sixMonths)
Text("Yearly").tag(ChartRange.year)
}
HStack {
Text("Quantity Type:")
.bold()
Spacer(minLength: 5)
Text(quantityType.identifier)
}
HStack {
Text("Chart Range (Binding):")
.bold()
Spacer(minLength: 5)
Text("\(range.domain.lowerBound.formatted()) - \(range.domain.upperBound.formatted())")
}
HStack {
Text("Chart Style (Modifier):")
.bold()
Spacer(minLength: 5)
Text("\(chartStyle.frameSize)")
}
HStack {
Text("Disabled Interactions (Modifier):")
.bold()
Spacer(minLength: 5)
Text(String(disabledInteractions.rawValue, radix: 2))
}
Section("Measurements") {
ForEach(measurements, id: \.self) { measurement in
Text("\(measurement)")
}
}
}
.onChange(of: range) { _, _ in
measurements.append(measurements.reduce(0, +))
}
}


init(
_ type: HKQuantityType,
range: Binding<ChartRange>,
provider: any DataProvider = HealthKitDataProvider()
) {
self._range = range
self.quantityType = type
self.dataProvider = provider
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import SwiftUI


extension EnvironmentValues {
@Entry var disabledChartInteractions: HealthChartInteractions = []
}


extension View {
///
public func healthChartInteractions(disabled disabledValues: HealthChartInteractions) -> some View {
// TODO: Handle reduction - get current value from environment, combine with new value, and inject back into environment.
environment(\.disabledChartInteractions, disabledValues)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import SwiftUI


extension EnvironmentValues {
@Entry var healthChartStyle: HealthChartStyle = .default
}


extension View {
// TODO: Add argument here to control how we combine styles (e.g. .override, .combine, etc.)
public func style(_ newValue: HealthChartStyle) -> some View {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a naming like healthChartStyle(_:) would be more unique and fit great with existing SwiftUI APIs (like buttonStyle(_:) or labelStye(_:), ...).

environment(\.healthChartStyle, newValue)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import Foundation
import HealthKit


/// A class conforming to `DataProvider` multiplexes to fetch the data from a specific data store in the form of `HKQuantitySample`s.
///
/// Implementation of `.fetchData()` should query all data of that type once, cache the data, and aggregate it according to the provided granularity.
/// The aggregation is preferrably done by the data store (i.e. `HealthStore`), with only the final data array being stored on device.
///
/// Then, only data points within the `ChartRange` will be shown in the `HealthChart`.
///
/// Default implementation fetches `HealthKitDataProvider` fetches data from a HealthKit `HealthStore`.
public protocol DataProvider: Sendable {
// TODO: Pass `ChartRange` instead of interval, query all health data at once aggregated by `DateRange` granularity.
func fetchData(for measurementType: HKQuantityType, in chartRange: ChartRange) async throws -> [HKQuantitySample]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import Foundation
import HealthKit


public final class HealthKitDataProvider: DataProvider {
private let healthStore = HKHealthStore()
private let cache: NSCache<NSString, NSArray> = NSCache()

Check warning on line 15 in Sources/SpeziHealthCharts/MetricChart/DataProvider/HealthKitDataProvider/HealthKitDataProvider.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package / Test using xcodebuild or run fastlane

stored property 'cache' of 'Sendable'-conforming class 'HealthKitDataProvider' has non-sendable type 'NSCache<NSString, NSArray>'; this is an error in the Swift 6 language mode

Check warning on line 15 in Sources/SpeziHealthCharts/MetricChart/DataProvider/HealthKitDataProvider/HealthKitDataProvider.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package / Test using xcodebuild or run fastlane

stored property 'cache' of 'Sendable'-conforming class 'HealthKitDataProvider' has non-sendable type 'NSCache<NSString, NSArray>'; this is an error in the Swift 6 language mode

Check warning on line 15 in Sources/SpeziHealthCharts/MetricChart/DataProvider/HealthKitDataProvider/HealthKitDataProvider.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests / Test using xcodebuild or run fastlane

stored property 'cache' of 'Sendable'-conforming class 'HealthKitDataProvider' has non-sendable type 'NSCache<NSString, NSArray>'; this is an error in the Swift 6 language mode

Check warning on line 15 in Sources/SpeziHealthCharts/MetricChart/DataProvider/HealthKitDataProvider/HealthKitDataProvider.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests / Test using xcodebuild or run fastlane

stored property 'cache' of 'Sendable'-conforming class 'HealthKitDataProvider' has non-sendable type 'NSCache<NSString, NSArray>'; this is an error in the Swift 6 language mode


/// Returns a unique cache key for a given `HKQuantityType` and `granularity`.
private func cacheKey(for type: HKQuantityType, granularity: Calendar.Component) -> NSString {
"\(type.identifier)_\(granularity)" as NSString
}

/// Queries an internal instance of a `HealthStore` for all past `HKQuantity`s of type `measurementType`, and stores the result in an `NSCache`.
/// Returns an array of `HKQuantitySamples` whose timestamps fall in the given `ChartRange.domain`.
///
/// If a previous query has already fetched the given type at the given granularity, returns that result from the cache.
/// Passes a query to `HealthStore` that aggregates the resulting measurement by averaging over the given granularity.
///
/// NOTE: `HKStatisticsCollectionQuery` can only be used with `HKQuantitySample`s. For `HKCorrelationSample`s, we'll need to aggregate/process
/// the data ourselves. See https://developer.apple.com/documentation/healthkit/hkstatisticscollectionquery.
public func fetchData(for measurementType: HKQuantityType, in chartRange: ChartRange) async throws -> [HKQuantitySample] {

Check failure on line 31 in Sources/SpeziHealthCharts/MetricChart/DataProvider/HealthKitDataProvider/HealthKitDataProvider.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests / Test using xcodebuild or run fastlane

Function Body Length Violation: Function body should span 50 lines or less excluding comments and whitespace: currently spans 59 lines (function_body_length)
// First, check the cache to see if we've previously queried this type.
let key = cacheKey(for: measurementType, granularity: chartRange.granularity)
if let samples = cache.object(forKey: key) as? [HKQuantitySample] {
// The cache contains samples, so we should return the samples in the domain of the given `ChartRange`.
return samples.filter {
// TODO: Think about what date to use.
// $0.startDate = start of interval that $0 is the average of.
// $0.endDate = end of interval that $0 is the average of.
chartRange.domain.contains($0.startDate)
}
}

// The cache does not contain any samples, so we need to query HealthStore.

// Create date interval components based on granularity.
let intervalComponents: DateComponents = {
var components = DateComponents()
switch chartRange.granularity {
case .hour:
components.hour = 1
case .day:
components.day = 1
case .weekOfYear:
components.weekOfYear = 1
case .month:
components.month = 1
case .year:
components.year = 1
default:
components.day = 1 // Default to daily if unsupported granularity
}
return components
}()

let query = HKStatisticsCollectionQuery(
quantityType: measurementType,
quantitySamplePredicate: nil, // No predicate so that we query all the samples.
options: .discreteAverage, // Aggregate by averaging.
anchorDate: .distantPast, // Start at the oldest sample.
intervalComponents: intervalComponents // Aggregate over components determined by granularity of `chartRange`.
)

return try await withCheckedThrowingContinuation { continuation in
query.initialResultsHandler = { query, collection, error in
// If there's been an error, throw the error on the query thread.
if let error {
continuation.resume(throwing: error)
return
}

// If the query results in no collection, there were no samples found, so return an empty array.
guard let collection else {
continuation.resume(returning: [])
return
}

// Enumerate over the statistics collections and add the aggregated sample to our samples array.
var samples: [HKQuantitySample] = []
collection.enumerateStatistics(from: .distantPast, to: .now) { statistics, _ in // TODO: Understand the 2nd arg
guard let average = statistics.averageQuantity() else {
return
}

// Initialize a new `HKQuantitySample` representing the average of the samples from
// `statistics.startDate` to `statistics.endDate`.
let newSample = HKQuantitySample(
type: measurementType,
quantity: average,
start: statistics.startDate,
end: statistics.endDate
)

samples.append(newSample)
}

// Add the samples to the cache.
self.cache.setObject(samples as NSArray, forKey: key)

Check warning on line 108 in Sources/SpeziHealthCharts/MetricChart/DataProvider/HealthKitDataProvider/HealthKitDataProvider.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package / Test using xcodebuild or run fastlane

capture of 'key' with non-sendable type 'NSString' in a `@Sendable` closure; this is an error in the Swift 6 language mode

Check warning on line 108 in Sources/SpeziHealthCharts/MetricChart/DataProvider/HealthKitDataProvider/HealthKitDataProvider.swift

View workflow job for this annotation

GitHub Actions / Build and Test UI Tests / Test using xcodebuild or run fastlane

capture of 'key' with non-sendable type 'NSString' in a `@Sendable` closure; this is an error in the Swift 6 language mode

// TODO: Enforce thread safety. Should we be returning on a potentially non-main actor isolated thread?
continuation.resume(returning: samples)
}

healthStore.execute(query)
}
}

// TODO: Add functionality for invalidating the cache?


public init() {}
}
Loading
Loading