diff --git a/Package.swift b/Package.swift index 1899ae7..0173f73 100644 --- a/Package.swift +++ b/Package.swift @@ -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") @@ -43,6 +43,17 @@ let package = Package( ], plugins: [] + swiftLintPlugin() ), + .target( + name: "SpeziHealthCharts", + dependencies: [ + .product(name: "Spezi", package: "Spezi") + ], + swiftSettings: [ + swiftConcurrency, + .enableUpcomingFeature("InferSendableFromCaptures") + ], + plugins: [] + swiftLintPlugin() + ), .testTarget( name: "SpeziHealthKitTests", dependencies: [ diff --git a/Sources/SpeziHealthCharts/MetricChart/ChartViews/HealthChart.swift b/Sources/SpeziHealthCharts/MetricChart/ChartViews/HealthChart.swift new file mode 100644 index 0000000..253615a --- /dev/null +++ b/Sources/SpeziHealthCharts/MetricChart/ChartViews/HealthChart.swift @@ -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? + + var range: Binding { + 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() + ) { + self.quantityType = type + self.privateRange = initialRange + self.dataProvider = provider + } + + public init( + _ type: HKQuantityType, + range: Binding, + provider: any DataProvider = HealthKitDataProvider() + ) { + self.privateRange = range.wrappedValue + self.privateRangeBinding = range + self.quantityType = type + self.dataProvider = provider + } +} diff --git a/Sources/SpeziHealthCharts/MetricChart/ChartViews/InternalHealthChart.swift b/Sources/SpeziHealthCharts/MetricChart/ChartViews/InternalHealthChart.swift new file mode 100644 index 0000000..b8cb8f1 --- /dev/null +++ b/Sources/SpeziHealthCharts/MetricChart/ChartViews/InternalHealthChart.swift @@ -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, + provider: any DataProvider = HealthKitDataProvider() + ) { + self._range = range + self.quantityType = type + self.dataProvider = provider + } +} diff --git a/Sources/SpeziHealthCharts/MetricChart/ChartViews/Modifiers/HealthChart+DisableInteractions.swift b/Sources/SpeziHealthCharts/MetricChart/ChartViews/Modifiers/HealthChart+DisableInteractions.swift new file mode 100644 index 0000000..0c31686 --- /dev/null +++ b/Sources/SpeziHealthCharts/MetricChart/ChartViews/Modifiers/HealthChart+DisableInteractions.swift @@ -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) + } +} diff --git a/Sources/SpeziHealthCharts/MetricChart/ChartViews/Modifiers/HealthChart+Style.swift b/Sources/SpeziHealthCharts/MetricChart/ChartViews/Modifiers/HealthChart+Style.swift new file mode 100644 index 0000000..dbaecfb --- /dev/null +++ b/Sources/SpeziHealthCharts/MetricChart/ChartViews/Modifiers/HealthChart+Style.swift @@ -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 { + environment(\.healthChartStyle, newValue) + } +} diff --git a/Sources/SpeziHealthCharts/MetricChart/DataProvider/DataProviderProtocol.swift b/Sources/SpeziHealthCharts/MetricChart/DataProvider/DataProviderProtocol.swift new file mode 100644 index 0000000..0cb2a14 --- /dev/null +++ b/Sources/SpeziHealthCharts/MetricChart/DataProvider/DataProviderProtocol.swift @@ -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] +} diff --git a/Sources/SpeziHealthCharts/MetricChart/DataProvider/HealthKitDataProvider/HealthKitDataProvider.swift b/Sources/SpeziHealthCharts/MetricChart/DataProvider/HealthKitDataProvider/HealthKitDataProvider.swift new file mode 100644 index 0000000..8623de3 --- /dev/null +++ b/Sources/SpeziHealthCharts/MetricChart/DataProvider/HealthKitDataProvider/HealthKitDataProvider.swift @@ -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 = NSCache() + + + /// 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] { + // 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) + + // 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() {} +} diff --git a/Sources/SpeziHealthCharts/MetricChart/Models/ChartRange.swift b/Sources/SpeziHealthCharts/MetricChart/Models/ChartRange.swift new file mode 100644 index 0000000..248dbe5 --- /dev/null +++ b/Sources/SpeziHealthCharts/MetricChart/Models/ChartRange.swift @@ -0,0 +1,108 @@ +// +// 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 + + +/// A `ChartRange` is the date domain of the x-axis of a `HealthChart`. +public struct ChartRange: Sendable, Equatable, Hashable { + public var domain: ClosedRange + public var granularity: Calendar.Component // Granularity ranges from `.hour` to `.month` + + + public init(start: Date, end: Date, granularity: Calendar.Component) { + self.domain = start...end + self.granularity = granularity + } + + public init(_ domain: ClosedRange, granularity: Calendar.Component) { + self.domain = domain + self.granularity = granularity + } + + + /// The last 24 hours relative to the current time, with a granularity of `.hour`. + public static let day: ChartRange = { + let end = Date() + let start = Calendar.current.date(byAdding: .day, value: -1, to: end) ?? end + + return ChartRange(start: start, end: end, granularity: .hour).rounded() + }() + + /// The last 7 days relative to the current time, with a granularity of `.day`. + public static let week: ChartRange = { + let end = Date() + let start = Calendar.current.date(byAdding: .day, value: -7, to: end) ?? end + + return ChartRange(start: start, end: end, granularity: .day).rounded() + }() + + /// The last month relative to the current time, with a granularity of `.day`. + public static let month: ChartRange = { + let end = Date() + let start = Calendar.current.date(byAdding: .month, value: -1, to: end) ?? end + + return ChartRange(start: start, end: end, granularity: .day).rounded() + }() + + /// The last six months relative to the current time, with a granularity of `.weekOfYear`. + public static let sixMonths: ChartRange = { + let end = Date() + let start = Calendar.current.date(byAdding: .month, value: -6, to: end) ?? end + + return ChartRange(start: start, end: end, granularity: .weekOfYear).rounded() + }() + + /// The last year relative to the current time, with a granularity of `.month`. + public static let year: ChartRange = { + let end = Date() + let start = Calendar.current.date(byAdding: .year, value: -1, to: end) ?? end + + return ChartRange(start: start, end: end, granularity: .month).rounded() + }() +} + + +extension ChartRange { + /// Rounds the domain boundaries to complete units of the specified granularity. + /// For example, if granularity is `.hour`, the domain will be extended to the nearest hour. + private func roundedDomain(calendar: Calendar) -> ClosedRange { + let components: Set = { + switch self.granularity { + case .hour: + return [.year, .month, .day, .hour] + case .day: + return [.year, .month, .day] + case .weekOfYear, .weekOfMonth: + return [.yearForWeekOfYear, .weekOfYear] + case .month: + return [.year, .month] + case .year: + return [.year] + default: + return [] + } + }() + + let startComponents = calendar.dateComponents(components, from: self.domain.lowerBound) + let endComponents = calendar.dateComponents(components, from: self.domain.upperBound) + + let roundedStart = calendar.date(from: startComponents) ?? self.domain.lowerBound + + // For the upper bound, we want to go to the end of the component. + let endComponentStart = calendar.date(from: endComponents) ?? self.domain.upperBound + let roundedEnd = calendar.date(byAdding: self.granularity, value: 1, to: endComponentStart) ?? self.domain.upperBound + + return roundedStart...roundedEnd + } + + /// Creates a new `ChartRange` with domain boundaries rounded to complete granularity units. + public func rounded(using calendar: Calendar = .current) -> ChartRange { + ChartRange(self.roundedDomain(calendar: calendar), granularity: self.granularity) + } +} diff --git a/Sources/SpeziHealthCharts/MetricChart/Models/HealthChartInteractions.swift b/Sources/SpeziHealthCharts/MetricChart/Models/HealthChartInteractions.swift new file mode 100644 index 0000000..462d1b7 --- /dev/null +++ b/Sources/SpeziHealthCharts/MetricChart/Models/HealthChartInteractions.swift @@ -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 +// + + +public struct HealthChartInteractions: OptionSet, Sendable { + public let rawValue: Int + + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + + public static let swipe: HealthChartInteractions = HealthChartInteractions(rawValue: 1 << 0) + public static let tap: HealthChartInteractions = HealthChartInteractions(rawValue: 1 << 1) + + public static let all: HealthChartInteractions = [.tap, .swipe] +} diff --git a/Sources/SpeziHealthCharts/MetricChart/Models/HealthChartStyle.swift b/Sources/SpeziHealthCharts/MetricChart/Models/HealthChartStyle.swift new file mode 100644 index 0000000..248f1e7 --- /dev/null +++ b/Sources/SpeziHealthCharts/MetricChart/Models/HealthChartStyle.swift @@ -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 Foundation + + +public struct HealthChartStyle: Sendable { + let frameSize: CGFloat + + + public init(idealHeight: CGFloat = 200.0) { + frameSize = idealHeight + } + + + public static let `default` = HealthChartStyle() +} + diff --git a/Sources/SpeziHealthCharts/MetricChart/Models/MeasurementCache.swift b/Sources/SpeziHealthCharts/MetricChart/Models/MeasurementCache.swift new file mode 100644 index 0000000..5ef4f17 --- /dev/null +++ b/Sources/SpeziHealthCharts/MetricChart/Models/MeasurementCache.swift @@ -0,0 +1,79 @@ +// +// 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 + + +/// This functionality will be moved to the `HealthKitDataProvider` implementation. +actor MeasurementCache { + enum CacheError: LocalizedError { + case entryNotFound + case entryExpired + + var errorDescription: String? { + switch self { + case .entryNotFound: + String(localized: "No entries found for given key.") + case .entryExpired: + String(localized: "Entry found in cache has expired.") + } + } + } + + struct CacheKey: Hashable { + let type: HKQuantityType + let range: ClosedRange + } + + struct CacheValue { + let measurements: [HKQuantitySample] + let timestamp: Date + } + + private var cache: [CacheKey: CacheValue] = [:] + + private let maxEntries = 10 + private let ageLimit: TimeInterval = 24 * 60 * 60 // Time limit is one day. + + + func store(_ measurements: [HKQuantitySample], for type: HKQuantityType, range dateRange: ChartRange) { + let key = CacheKey(type: type, range: dateRange.domain) + self.cache[key] = CacheValue(measurements: measurements, timestamp: Date()) + + if self.cache.count > self.maxEntries { + self.cleanUpOldEntries() + } + } + + + func fetch(for type: HKQuantityType, range: ClosedRange) throws -> [HKQuantitySample] { + let key = CacheKey(type: type, range: range) + + guard let entry = self.cache[key] else { + // No entry matching the key is in the cache. + throw CacheError.entryNotFound + } + + guard Date().timeIntervalSince(entry.timestamp) > self.ageLimit else { + // Entry has been cached for too long -- it has expired. + cache.removeValue(forKey: key) + throw CacheError.entryExpired + } + + return entry.measurements + } + + + private func cleanUpOldEntries() { + let now = Date() + self.cache = self.cache.filter { _, value in + now.timeIntervalSince(value.timestamp) <= self.ageLimit + } + } +} diff --git a/Tests/SpeziChartsTests/SpeziHealthChartsTests.swift b/Tests/SpeziChartsTests/SpeziHealthChartsTests.swift new file mode 100644 index 0000000..87a1230 --- /dev/null +++ b/Tests/SpeziChartsTests/SpeziHealthChartsTests.swift @@ -0,0 +1,20 @@ +// +// 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 +// + +@testable import SpeziHealthCharts +import XCTest +import XCTSpezi + + +final class SpeziHealthChartsTests: XCTestCase { + func testMeasurementCacheSuccess() { + let measurementCache = MeasurementCache() + + let measurements = [DataPoint(value: 20, timestamp: Date(), type: )] + } +} diff --git a/Tests/UITests/TestApp/HealthKitTestsView.swift b/Tests/UITests/TestApp/HealthKitTestsView.swift index 3cd0981..cd1cfb0 100644 --- a/Tests/UITests/TestApp/HealthKitTestsView.swift +++ b/Tests/UITests/TestApp/HealthKitTestsView.swift @@ -7,6 +7,7 @@ // import SpeziHealthKit +import SpeziHealthCharts import SpeziViews import SwiftUI @@ -15,6 +16,11 @@ struct HealthKitTestsView: View { @Environment(HealthKit.self) var healthKitModule @Environment(HealthKitStore.self) var healthKitStore + @State private var showHealthChartBinding = false + @State private var showHealthChart = false + + @State private var chartRange: ChartRange = .month + var body: some View { List { @@ -39,6 +45,35 @@ struct HealthKitTestsView: View { } } } + Button("Show HealthChart with binding") { showHealthChartBinding.toggle() } + Button("Show HealthChart without binding") { showHealthChart.toggle() } } + .sheet(isPresented: $showHealthChartBinding) { + VStack { + Picker("Chart Range", selection: $chartRange) { + 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) + } + Text("\(chartRange.domain.lowerBound.formatted()) - \(chartRange.domain.upperBound.formatted())") + HealthChart(HKQuantityType(.bodyMass), range: $chartRange) + .style(HealthChartStyle(idealHeight: 150)) + } + } + + .sheet(isPresented: $showHealthChart) { + HealthChart(HKQuantityType(.bodyMass)) + .healthChartInteractions(disabled: .swipe) + } } } + + +#Preview { + HealthKitTestsView() + .previewWith(standard: HealthKitTestAppStandard()) { + HealthKit() + } +}