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 11 commits into
base: HealthChart/main
Choose a base branch
from
14 changes: 13 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// swift-tools-version:5.9
// TODO: Integrate Andreas' feedback to bump this up to 6.0

Check failure on line 2 in Package.swift

View workflow job for this annotation

GitHub Actions / SwiftLint / SwiftLint / SwiftLint

Todo Violation: TODOs should be resolved (Integrate Andreas' feedback to...) (todo)

//
// This source file is part of the Stanford Spezi open-source project
Expand Down Expand Up @@ -26,7 +27,7 @@
.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 +44,17 @@
],
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
26 changes: 26 additions & 0 deletions Sources/SpeziHealthCharts/MetricChart/ChartViews/ChartHeader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//
// 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


struct ChartHeader: View {
@Binding var range: ChartRange


var body: some View {
Picker("Date", selection: $range) {
Text("D").tag(ChartRange.day)
Text("W").tag(ChartRange.week)
Text("M").tag(ChartRange.month)
Text("6M").tag(ChartRange.sixMonths)
Text("Y").tag(ChartRange.year)
}
.pickerStyle(.segmented)
}
}
29 changes: 29 additions & 0 deletions Sources/SpeziHealthCharts/MetricChart/ChartViews/ChartPlot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// 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 Charts
import HealthKit
import SwiftUI


struct ChartPlot: View {
let samples: [HKQuantitySample]
let range: ChartRange
let unit: HKUnit


var body: some View {
Chart(samples, id: \.self) { sample in
BarMark(
x: .value("Date", sample.startDate, unit: range.granularity),
y: .value("Value", sample.quantity.doubleValue(for: unit))
)
}
.chartXScale(domain: range.domain)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//
// 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 DevInternalHealthChart: 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 {

Check failure on line 27 in Sources/SpeziHealthCharts/MetricChart/ChartViews/DevInternalHealthChart.swift

View workflow job for this annotation

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

Closure Body Length Violation: Closure body should span 35 lines or less excluding comments and whitespace: currently spans 36 lines (closure_body_length)
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
}
}

83 changes: 83 additions & 0 deletions Sources/SpeziHealthCharts/MetricChart/ChartViews/HealthChart.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// 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!

Copy link
Member

Choose a reason for hiding this comment

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

tldr: There is a slight technical different, though, it is not 100% clear if it makes any difference. I think it shouldn't impact your development work right now as the interfaces might not be fully there where they will be. Chances are it might be refactored anyways. And if it works it works.

Long answer:

You are right, it seems that from a view-update perspective everything works fine. Either it's just because the parent view refreshes or the Bindings transaction actually changes from a Equatable perspective.

What I head in the back of my head: Binding conforms to DynamicProperty (basically every SwiftUI property does to know when to prepare for the next view update). SwiftUI would then just inspect every DynamicProperty conforming property of the type and call update() accordingly. However, Optional does not conform to DynamicProperty, even if its Wrapped type does. I made a small example below. If foo is declared as optional, update() is never called, if you declare it as non-optional it will be called.

There is two questions here: a) does Binding contain any important logic in its update() method and how would it influence behavior (e.g., properties like @Environment or @State make sure to have a consistent view on the data while body is getting called) and b) does SwiftUI workaround this internally and has custom handling for optional types of e.g. Binding. So honestly not sure if it actually makes a difference at all.

struct Foo: DynamicProperty {
    func update() {
        print("UPDATE")
    }
}

struct MyView: View {
    var a: Foo? = Foo()

    var body: some View {
        Text("Hello World")
    }
}

Copy link
Author

Choose a reason for hiding this comment

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

After discussing with @PSchmiedmayer, we had the following thoughts:

Potential workaround that addresses the fact that Optional does not conform to DynamicProperty:

Make Optional conform to DynamicProperty when it's wrapped value conforms to DynamicProperty. The update() function will then just call the wrapped values venation if it's not nil, and do nothing otherwise.

Note: Apple/SwiftUI may already have native support to work around this, as we are not the first to encounter this issue.


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
private let sampleUnits: HKUnit


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


public init(
_ type: HKQuantityType,
in initialRange: ChartRange = .month,
unit: HKUnit,
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 👍

) {
assert(type.is(compatibleWith: unit), "Provided HKUnits must be compatible with the target HKQuantityType.")

self.quantityType = type
self.privateRange = initialRange
self.dataProvider = provider
self.sampleUnits = unit
}

public init(
_ type: HKQuantityType,
range: Binding<ChartRange>,
unit: HKUnit,
provider: any DataProvider = HealthKitDataProvider()
) {
assert(type.is(compatibleWith: unit), "Provided HKUnits must be compatible with the target HKQuantityType.")

self.privateRange = range.wrappedValue
self.privateRangeBinding = range
self.quantityType = type
self.sampleUnits = unit
self.dataProvider = provider
}

public init(
_ samples: [HKQuantitySample],
type: HKQuantityType,
range: ChartRange,
unit: HKUnit
) {
assert(type.is(compatibleWith: unit), "Provided HKUnits must be compatible with the target HKQuantityType.")

self.privateRange = range
self.quantityType = type
self.sampleUnits = unit
self.dataProvider = FixedSamplesDataProvider(samples: samples)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//
// 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 Charts
import HealthKit
import SpeziViews

Check failure on line 11 in Sources/SpeziHealthCharts/MetricChart/ChartViews/InternalHealthChart.swift

View workflow job for this annotation

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

no such module 'SpeziViews'
import SwiftUI


struct InternalHealthChart: View {
@Binding private var range: ChartRange
@State private var samples: [HKQuantitySample] = []

@State private var viewState: ViewState = .idle


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


private let quantityType: HKQuantityType
private let dataProvider: any DataProvider
private let unit: HKUnit


var body: some View {
Group {
if viewState == .idle {
VStack {
ChartHeader(range: $range)
ChartPlot(samples: samples, range: range, unit: unit)
}
} else {
ProgressView("Fetching Data...")
}
}
.applyHealthChartStyle(chartStyle)
.onChange(of: range) { _, newRange in
Task { @MainActor in
do {
self.samples = try await self.dataProvider.fetchData(for: quantityType, in: newRange)
} catch {
self.viewState = .error(
AnyLocalizedError(
error: error,
defaultErrorDescription: "Failed to fetch samples."
)
)
}
}
}
.task {
do {
self.samples = try await self.dataProvider.fetchData(for: quantityType, in: range)
} catch {
self.viewState = .error(
AnyLocalizedError(
error: error,
defaultErrorDescription: "Failed to fetch samples."
)
)
}
}
.viewStateAlert(state: $viewState)
}


init(
_ type: HKQuantityType,
range: Binding<ChartRange>,
unit: HKUnit,
provider: any DataProvider = HealthKitDataProvider()
) {
self._range = range
self.quantityType = type
self.dataProvider = provider
self.unit = unit
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// 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 View {
func applyHealthChartStyle(_ chartStyle: HealthChartStyle) -> some View {
self
.frame(idealHeight: chartStyle.frameSize)
}
}
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 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)
}
}
Loading
Loading