From 4ac675731b06102fc1ad61d0197c2227add28c85 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sat, 22 Jun 2024 11:38:47 +0200 Subject: [PATCH] Move HealthMeasurements to SpeziDevices --- Sources/SpeziDevices/DeviceManager.swift | 6 +- .../SpeziDevices/Devices/GenericDevice.swift | 2 +- .../BloodPressureMeasurement+HKSample.swift | 95 ++++++++++++ ...BloodPressureMeasurement.Unit+HKUnit.swift | 25 ++++ .../Health/WeightMeasurement+HKSample.swift | 43 ++++++ .../WeightMeasurement.Unit+HKUnit.swift | 35 +++++ .../Measurements/HealthMeasurement.swift | 15 ++ .../Measurements/HealthMeasurements.swift | 121 +++++++++++++++ .../HealthMeasurementsConstraint.swift | 15 ++ .../ProcessedHealthMeasurement.swift | 27 ++++ .../Testing/MockBluetoothPeripheral.swift | 15 +- Sources/SpeziDevices/Testing/MockDevice.swift | 140 ++++++++++++++++++ .../Devices/DeviceDetailsView.swift | 3 + .../SpeziDevicesUI/Devices/DevicesGrid.swift | 4 +- .../SpeziDevicesUI/Devices/NameEditView.swift | 3 + .../BloodPressureMeasurementLabel.swift | 72 +++++++++ .../Measurements/CloseButtonLayer.swift | 45 ++++++ .../ConfirmMeasurementButton.swift | 66 +++++++++ .../Measurements/MeasurementLayer.swift | 55 +++++++ .../MeasurementRecordedView.swift | 89 +++++++++++ .../Measurements/WeightMeasurementLabel.swift | 38 +++++ .../Pairing/AccessoryImageView.swift | 3 + .../Pairing/AccessorySetupSheet.swift | 3 + .../Pairing/PairDeviceView.swift | 3 + .../Pairing/PairedDeviceView.swift | 3 + .../Resources/Localizable.xcstrings | 28 ++++ .../Scanning/NearbyDeviceRow.swift | 3 + .../SpeziDevicesUI/Testing/MockDevice.swift | 67 --------- .../Testing/TestMeasurementStandard.swift | 20 +++ .../SpeziDevicesUI/Tips/ConfigureTipKit.swift | 36 ----- 30 files changed, 963 insertions(+), 117 deletions(-) create mode 100644 Sources/SpeziDevices/Health/BloodPressureMeasurement+HKSample.swift create mode 100644 Sources/SpeziDevices/Health/BloodPressureMeasurement.Unit+HKUnit.swift create mode 100644 Sources/SpeziDevices/Health/WeightMeasurement+HKSample.swift create mode 100644 Sources/SpeziDevices/Health/WeightMeasurement.Unit+HKUnit.swift create mode 100644 Sources/SpeziDevices/Measurements/HealthMeasurement.swift create mode 100644 Sources/SpeziDevices/Measurements/HealthMeasurements.swift create mode 100644 Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift create mode 100644 Sources/SpeziDevices/Measurements/ProcessedHealthMeasurement.swift rename Sources/{SpeziDevicesUI => SpeziDevices}/Testing/MockBluetoothPeripheral.swift (55%) create mode 100644 Sources/SpeziDevices/Testing/MockDevice.swift create mode 100644 Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift create mode 100644 Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift create mode 100644 Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift create mode 100644 Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift create mode 100644 Sources/SpeziDevicesUI/Measurements/MeasurementRecordedView.swift create mode 100644 Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift delete mode 100644 Sources/SpeziDevicesUI/Testing/MockDevice.swift create mode 100644 Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift delete mode 100644 Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift diff --git a/Sources/SpeziDevices/DeviceManager.swift b/Sources/SpeziDevices/DeviceManager.swift index aeed79a..1312f5c 100644 --- a/Sources/SpeziDevices/DeviceManager.swift +++ b/Sources/SpeziDevices/DeviceManager.swift @@ -12,15 +12,11 @@ import SpeziBluetooth import SpeziBluetoothServices import SwiftUI -// TODO: Start SpeziDevices generalization // TODO: Finish SpeziBluetooth refactoring and cleanup "persistent devices" // TODO: dark mode device images -// TODO: ask for more Omron infos? secret sauce? - -// TODO: move deviceManager to SpeziBluetooth (and measurement manager?) @Observable -public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitializable { +public final class DeviceManager: Module, EnvironmentAccessible, DefaultInitializable { // TODO: "PairedDevices" rename? /// Determines if the device discovery sheet should be presented. @MainActor public var presentingDevicePairing = false // TODO: "should" naming @MainActor public private(set) var discoveredDevices: OrderedDictionary = [:] diff --git a/Sources/SpeziDevices/Devices/GenericDevice.swift b/Sources/SpeziDevices/Devices/GenericDevice.swift index 3ead72d..27ea049 100644 --- a/Sources/SpeziDevices/Devices/GenericDevice.swift +++ b/Sources/SpeziDevices/Devices/GenericDevice.swift @@ -14,7 +14,7 @@ import SpeziBluetoothServices /// A generic Bluetooth device. /// /// A generic Bluetooth device that provides access to basic device information. -public protocol GenericDevice: BluetoothDevice, GenericBluetoothPeripheral { +public protocol GenericDevice: BluetoothDevice, GenericBluetoothPeripheral, Identifiable { /// The device identifier. /// /// Use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) property wrapper to diff --git a/Sources/SpeziDevices/Health/BloodPressureMeasurement+HKSample.swift b/Sources/SpeziDevices/Health/BloodPressureMeasurement+HKSample.swift new file mode 100644 index 0000000..d1a4bdf --- /dev/null +++ b/Sources/SpeziDevices/Health/BloodPressureMeasurement+HKSample.swift @@ -0,0 +1,95 @@ +// +// 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 SpeziBluetoothServices + + +extension BloodPressureMeasurement { + public func bloodPressureSample(source device: HKDevice?) -> HKCorrelation? { + guard systolicValue.isFinite, diastolicValue.isFinite else { + return nil + } + let unit: HKUnit = unit.hkUnit + + let systolic = HKQuantity(unit: unit, doubleValue: systolicValue.double) + let diastolic = HKQuantity(unit: unit, doubleValue: diastolicValue.double) + + let systolicType = HKQuantityType(.bloodPressureSystolic) + let diastolicType = HKQuantityType(.bloodPressureDiastolic) + let correlationType = HKCorrelationType(.bloodPressure) + + let date = timeStamp?.date ?? .now + + let systolicSample = HKQuantitySample(type: systolicType, quantity: systolic, start: date, end: date, device: device, metadata: nil) + let diastolicSample = HKQuantitySample(type: diastolicType, quantity: diastolic, start: date, end: date, device: device, metadata: nil) + + + let bloodPressure = HKCorrelation( + type: correlationType, + start: date, + end: date, + objects: [systolicSample, diastolicSample], + device: device, + metadata: nil + ) + + return bloodPressure + } +} + + +extension BloodPressureMeasurement { + public func heartRateSample(source device: HKDevice?) -> HKQuantitySample? { + guard let pulseRate, pulseRate.isFinite else { + return nil + } + + // beats per minute + let bpm: HKUnit = .count().unitDivided(by: .minute()) + let pulseQuantityType = HKQuantityType(.heartRate) + + let pulse = HKQuantity(unit: bpm, doubleValue: pulseRate.double) + let date = timeStamp?.date ?? .now + + return HKQuantitySample( + type: pulseQuantityType, + quantity: pulse, + start: date, + end: date, + device: device, + metadata: nil + ) + } +} + + +#if DEBUG || TEST +extension HKCorrelation { + @_spi(TestingSupport) + public static var mockBloodPressureSample: HKCorrelation { + let measurement = BloodPressureMeasurement(systolic: 117, diastolic: 76, meanArterialPressure: 67, unit: .mmHg, pulseRate: 68) + guard let sample = measurement.bloodPressureSample(source: nil) else { + preconditionFailure("Mock sample was unexpectedly invalid!") + } + return sample + } +} + +extension HKQuantitySample { + @_spi(TestingSupport) + public static var mockHeartRateSample: HKQuantitySample { + let measurement = BloodPressureMeasurement(systolic: 117, diastolic: 76, meanArterialPressure: 67, unit: .mmHg, pulseRate: 68) + guard let sample = measurement.heartRateSample(source: nil) else { + preconditionFailure("Mock sample was unexpectedly invalid!") + } + return sample + } +} +#endif diff --git a/Sources/SpeziDevices/Health/BloodPressureMeasurement.Unit+HKUnit.swift b/Sources/SpeziDevices/Health/BloodPressureMeasurement.Unit+HKUnit.swift new file mode 100644 index 0000000..b62f1c2 --- /dev/null +++ b/Sources/SpeziDevices/Health/BloodPressureMeasurement.Unit+HKUnit.swift @@ -0,0 +1,25 @@ +// +// 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 SpeziBluetoothServices + + +extension BloodPressureMeasurement.Unit { + /// The unit represented as a `HKUnit`. + public var hkUnit: HKUnit { + switch self { + case .mmHg: + return .millimeterOfMercury() + case .kPa: + return .pascalUnit(with: .kilo) + } + } +} diff --git a/Sources/SpeziDevices/Health/WeightMeasurement+HKSample.swift b/Sources/SpeziDevices/Health/WeightMeasurement+HKSample.swift new file mode 100644 index 0000000..d5ef6de --- /dev/null +++ b/Sources/SpeziDevices/Health/WeightMeasurement+HKSample.swift @@ -0,0 +1,43 @@ +// +// 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 SpeziBluetoothServices + + +extension WeightMeasurement { + public func quantitySample(source device: HKDevice?, resolution: WeightScaleFeature.WeightResolution?) -> HKQuantitySample { + let quantityType = HKQuantityType(.bodyMass) + + let value = weight(of: resolution ?? .unspecified) + + let quantity = HKQuantity(unit: unit.massUnit, doubleValue: value) + let date = timeStamp?.date ?? .now + + return HKQuantitySample( + type: quantityType, + quantity: quantity, + start: date, + end: date, + device: device, + metadata: nil + ) + } +} + +#if DEBUG || TEST +extension HKQuantitySample { + @_spi(TestingSupport) + public static var mockWeighSample: HKQuantitySample { + let measurement = WeightMeasurement(weight: 8400, unit: .si) + + return measurement.quantitySample(source: nil, resolution: .resolution5g) + } +} +#endif diff --git a/Sources/SpeziDevices/Health/WeightMeasurement.Unit+HKUnit.swift b/Sources/SpeziDevices/Health/WeightMeasurement.Unit+HKUnit.swift new file mode 100644 index 0000000..439a526 --- /dev/null +++ b/Sources/SpeziDevices/Health/WeightMeasurement.Unit+HKUnit.swift @@ -0,0 +1,35 @@ +// +// 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 SpeziBluetoothServices + + +extension WeightMeasurement.Unit { + /// The mass unit represented as a `HKUnit`. + public var massUnit: HKUnit { + switch self { + case .si: + return .gramUnit(with: .kilo) + case .imperial: + return .pound() + } + } + + + /// The length unit represented as a `HKUnit`. + public var lengthUnit: HKUnit { + switch self { + case .si: + return .meter() + case .imperial: + return .inch() + } + } +} diff --git a/Sources/SpeziDevices/Measurements/HealthMeasurement.swift b/Sources/SpeziDevices/Measurements/HealthMeasurement.swift new file mode 100644 index 0000000..be405b4 --- /dev/null +++ b/Sources/SpeziDevices/Measurements/HealthMeasurement.swift @@ -0,0 +1,15 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetoothServices + + +public enum HealthMeasurement { + case weight(WeightMeasurement, WeightScaleFeature) + case bloodPressure(BloodPressureMeasurement, BloodPressureFeature) +} diff --git a/Sources/SpeziDevices/Measurements/HealthMeasurements.swift b/Sources/SpeziDevices/Measurements/HealthMeasurements.swift new file mode 100644 index 0000000..e39f33c --- /dev/null +++ b/Sources/SpeziDevices/Measurements/HealthMeasurements.swift @@ -0,0 +1,121 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import OSLog +import Spezi +import SpeziBluetooth +#if DEBUG +@_spi(TestingSupport) +#endif +import SpeziDevices + + +/// Manage and process incoming health measurements. +@Observable +public class HealthMeasurements: Module, EnvironmentAccessible, DefaultInitializable { + private let logger = Logger(subsystem: "ENGAGEHF", category: "HealthMeasurements") + + var newMeasurement: ProcessedHealthMeasurement? + + @StandardActor @ObservationIgnored private var standard: any HealthMeasurementsConstraint + @Dependency @ObservationIgnored private var bluetooth: Bluetooth? + + required public init() {} + + // TODO: rename! + public func handleNewMeasurement(_ measurement: HealthMeasurement, from device: Device) { + let hkDevice = device.hkDevice + + switch measurement { + case let .weight(measurement, feature): + let sample = measurement.quantitySample(source: hkDevice, resolution: feature.weightResolution) + logger.debug("Measurement loaded: \(measurement.weight)") + + newMeasurement = .weight(sample) + case let .bloodPressure(measurement, _): + let bloodPressureSample = measurement.bloodPressureSample(source: hkDevice) + let heartRateSample = measurement.heartRateSample(source: hkDevice) + + guard let bloodPressureSample else { + logger.debug("Discarding invalid blood pressure measurement ...") + return + } + + logger.debug("Measurement loaded: \(String(describing: measurement))") + + newMeasurement = .bloodPressure(bloodPressureSample, heartRate: heartRateSample) + } + } + + // TODO: docs everywhere! + + public func saveMeasurement() async throws { // TODO: rename? + if ProcessInfo.processInfo.isPreviewSimulator { + try await Task.sleep(for: .seconds(5)) + return + } + + guard let measurement = self.newMeasurement else { + logger.error("Attempting to save a nil measurement.") + return + } + + logger.info("Saving the following measurement: \(String(describing: measurement))") + do { + switch measurement { + case let .weight(sample): + try await standard.addMeasurement(sample: sample) + case let .bloodPressure(bloodPressureSample, heartRateSample): + try await standard.addMeasurement(sample: bloodPressureSample) + if let heartRateSample { + try await standard.addMeasurement(sample: heartRateSample) + } + } + } catch { + logger.error("Failed to save measurement samples: \(error)") + throw error + } + + logger.info("Save successful!") + newMeasurement = nil + } +} + + +#if DEBUG || TEST +extension HealthMeasurements { + /// Call in preview simulator wrappers. + /// + /// Loads a mock measurement to display in preview. + @_spi(TestingSupport) + public func loadMockWeightMeasurement() { + let device = MockDevice.createMockDevice() + + guard let measurement = device.weightScale.weightMeasurement else { + preconditionFailure("Mock Weight Measurement was never injected!") + } + + handleNewMeasurement(.weight(measurement, device.weightScale.features ?? []), from: device) + } + + /// Call in preview simulator wrappers. + /// + /// Loads a mock measurement to display in preview. + @_spi(TestingSupport) + public func loadMockBloodPressureMeasurement() { + let device = MockDevice.createMockDevice() + + guard let measurement = device.bloodPressure.bloodPressureMeasurement else { + preconditionFailure("Mock Blood Pressure Measurement was never injected!") + } + + handleNewMeasurement(.bloodPressure(measurement, device.bloodPressure.features ?? []), from: device) + } +} +#endif diff --git a/Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift b/Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift new file mode 100644 index 0000000..32115bc --- /dev/null +++ b/Sources/SpeziDevices/Measurements/HealthMeasurementsConstraint.swift @@ -0,0 +1,15 @@ +// +// 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 Spezi + + +public protocol HealthMeasurementsConstraint: Standard { + func addMeasurement(sample: HKSample) async throws +} diff --git a/Sources/SpeziDevices/Measurements/ProcessedHealthMeasurement.swift b/Sources/SpeziDevices/Measurements/ProcessedHealthMeasurement.swift new file mode 100644 index 0000000..b706a57 --- /dev/null +++ b/Sources/SpeziDevices/Measurements/ProcessedHealthMeasurement.swift @@ -0,0 +1,27 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import HealthKit + + +public enum ProcessedHealthMeasurement { // TODO: HealthKitSample with Hint? => StructuredHKSample? + case weight(HKQuantitySample) + case bloodPressure(HKCorrelation, heartRate: HKQuantitySample? = nil) +} + + +extension ProcessedHealthMeasurement: Identifiable { + public var id: UUID { + switch self { + case let .weight(sample): + sample.uuid + case let .bloodPressure(sample, _): + sample.uuid + } + } +} diff --git a/Sources/SpeziDevicesUI/Testing/MockBluetoothPeripheral.swift b/Sources/SpeziDevices/Testing/MockBluetoothPeripheral.swift similarity index 55% rename from Sources/SpeziDevicesUI/Testing/MockBluetoothPeripheral.swift rename to Sources/SpeziDevices/Testing/MockBluetoothPeripheral.swift index 3531dca..cd6d07e 100644 --- a/Sources/SpeziDevicesUI/Testing/MockBluetoothPeripheral.swift +++ b/Sources/SpeziDevices/Testing/MockBluetoothPeripheral.swift @@ -7,18 +7,21 @@ // import SpeziBluetooth -import SpeziDevices +#if DEBUG || TEST /// Mock peripheral used for internal previews. -struct MockBluetoothPeripheral: GenericBluetoothPeripheral { - var label: String - var state: PeripheralState - var requiresUserAttention: Bool +@_spi(TestingSupport) +public struct MockBluetoothPeripheral: GenericBluetoothPeripheral { + public let label: String + public let state: PeripheralState + public let requiresUserAttention: Bool - init(label: String, state: PeripheralState, requiresUserAttention: Bool = false) { + + public init(label: String, state: PeripheralState, requiresUserAttention: Bool = false) { self.label = label self.state = state self.requiresUserAttention = requiresUserAttention } } +#endif diff --git a/Sources/SpeziDevices/Testing/MockDevice.swift b/Sources/SpeziDevices/Testing/MockDevice.swift new file mode 100644 index 0000000..75ad5ab --- /dev/null +++ b/Sources/SpeziDevices/Testing/MockDevice.swift @@ -0,0 +1,140 @@ +// +// 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 +@_spi(TestingSupport) import SpeziBluetooth +import SpeziBluetoothServices +import SpeziNumerics + + +#if DEBUG || TEST +@_spi(TestingSupport) +public final class MockDevice: PairableDevice, HealthDevice { + @DeviceState(\.id) public var id + @DeviceState(\.name) public var name + @DeviceState(\.state) public var state + @DeviceState(\.advertisementData) public var advertisementData + @DeviceState(\.discarded) public var discarded + + @DeviceAction(\.connect) public var connect + @DeviceAction(\.disconnect) public var disconnect + + + @Service public var deviceInformation = DeviceInformationService() + + // Some mock health measurement services + @Service public var bloodPressure = BloodPressureService() + @Service public var weightScale = WeightScaleService() + + public let pairing = PairingContinuation() + public var isInPairingMode = false // TODO: control + + // TODO: mandatory setup? + public init() {} +} + + +extension MockDevice { + @_spi(TestingSupport) + public static func createMockDevice( + name: String = "Mock Device", + state: PeripheralState = .disconnected, + bloodPressureMeasurement: BloodPressureMeasurement = .mock(), + weightMeasurement: WeightMeasurement = .mock(), + weightResolution: WeightScaleFeature.WeightResolution = .resolution5g, + heightResolution: WeightScaleFeature.HeightResolution = .resolution1mm + ) -> MockDevice { + let device = MockDevice() + + device.deviceInformation.$manufacturerName.inject("Mock Company") + device.deviceInformation.$modelNumber.inject("MD1") + device.deviceInformation.$hardwareRevision.inject("2") + device.deviceInformation.$firmwareRevision.inject("1.0") + + device.$id.inject(UUID()) + device.$name.inject(name) + device.$state.inject(state) + + device.bloodPressure.$features.inject([ + .bodyMovementDetectionSupported, + .irregularPulseDetectionSupported + ]) + device.bloodPressure.$bloodPressureMeasurement.inject(bloodPressureMeasurement) + + device.weightScale.$features.inject(WeightScaleFeature( + weightResolution: weightResolution, + heightResolution: heightResolution, + options: .timeStampSupported + )) + device.weightScale.$weightMeasurement.inject(weightMeasurement) + + device.$connect.inject { @MainActor [weak device] in + device?.$state.inject(.connecting) + // TODO: await device?.handleStateChange(.connecting) + + try? await Task.sleep(for: .seconds(1)) + + device?.$state.inject(.connected) + // TODO: await device?.handleStateChange(.connected) + } + + device.$disconnect.inject { @MainActor [weak device] in + device?.$state.inject(.disconnected) + // TODO: await device?.handleStateChange(.disconnected) + } + + return device + } +} + + +extension BloodPressureMeasurement { + @_spi(TestingSupport) + public static func mock( + systolic: MedFloat16 = 103, + diastolic: MedFloat16 = 64, + meanArterialPressure: MedFloat16 = 77, + unit: BloodPressureMeasurement.Unit = .mmHg, + timeStamp: DateTime? = DateTime(year: 2024, month: .june, day: 5, hours: 12, minutes: 33, seconds: 11), + pulseRate: MedFloat16 = 62, + userId: UInt8 = 1, + status: BloodPressureMeasurement.Status = [] + ) -> BloodPressureMeasurement { + BloodPressureMeasurement( + systolic: systolic, + diastolic: diastolic, + meanArterialPressure: meanArterialPressure, + unit: unit, + timeStamp: timeStamp, + pulseRate: pulseRate, + userId: userId, + measurementStatus: status + ) + } +} + + +extension WeightMeasurement { + @_spi(TestingSupport) + public static func mock( + weight: UInt16 = 8400, + unit: WeightMeasurement.Unit = .si, + timeStamp: DateTime? = DateTime(year: 2024, month: .june, day: 5, hours: 12, minutes: 33, seconds: 11), + userId: UInt8? = nil, + additionalInfo: AdditionalInfo? = nil + ) -> WeightMeasurement { + WeightMeasurement( + weight: weight, + unit: unit, + timeStamp: timeStamp, + userId: userId, + additionalInfo: additionalInfo + ) + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift index 606b9bc..d12e370 100644 --- a/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift +++ b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift @@ -6,6 +6,9 @@ // SPDX-License-Identifier: MIT // +#if DEBUG +@_spi(TestingSupport) +#endif import SpeziDevices import SpeziViews import SwiftUI diff --git a/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift index 9b909b0..cecbb2a 100644 --- a/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift +++ b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift @@ -108,7 +108,7 @@ extension Binding: Hashable, Equatable where Value: Hashable { } .onAppear { Tips.showAllTipsForTesting() - try? Tips.configure() + try? Tips.configure() // TODO: use ConfigureTipKit Module } .previewWith { DeviceManager() @@ -126,7 +126,7 @@ extension Binding: Hashable, Equatable where Value: Hashable { } .onAppear { Tips.showAllTipsForTesting() - try? Tips.configure() + try? Tips.configure() // TODO: use ConfigureTipKit Module } .previewWith { DeviceManager() diff --git a/Sources/SpeziDevicesUI/Devices/NameEditView.swift b/Sources/SpeziDevicesUI/Devices/NameEditView.swift index 8b336a3..9487ab3 100644 --- a/Sources/SpeziDevicesUI/Devices/NameEditView.swift +++ b/Sources/SpeziDevicesUI/Devices/NameEditView.swift @@ -6,6 +6,9 @@ // SPDX-License-Identifier: MIT // +#if DEBUG +@_spi(TestingSupport) +#endif import SpeziDevices import SpeziValidation import SwiftUI diff --git a/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift b/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift new file mode 100644 index 0000000..cdd3ca7 --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift @@ -0,0 +1,72 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +#if DEBUG +@_spi(TestingSupport) +#endif +import SpeziDevices +import SwiftUI + + +struct BloodPressureMeasurementLabel: View { + private let bloodPressureSample: HKCorrelation + private let heartRateSample: HKQuantitySample? + + @ScaledMetric private var measurementTextSize: CGFloat = 50 + + private var bloodPressureQuantitySamples: [HKQuantitySample] { + bloodPressureSample.objects + .compactMap { sample in + sample as? HKQuantitySample + } + } + + private var systolic: HKQuantitySample? { + bloodPressureQuantitySamples + .first(where: { $0.quantityType == HKQuantityType(.bloodPressureSystolic) }) + } + + private var diastolic: HKQuantitySample? { + bloodPressureQuantitySamples + .first(where: { $0.quantityType == HKQuantityType(.bloodPressureDiastolic) }) + } + + var body: some View { + if let systolic, + let diastolic { + VStack(spacing: 5) { + Text("\(Int(systolic.quantity.doubleValue(for: .millimeterOfMercury())))/\(Int(diastolic.quantity.doubleValue(for: .millimeterOfMercury()))) mmHg") + .font(.system(size: measurementTextSize, weight: .bold, design: .rounded)) + + if let heartRateSample { + Text("\(Int(heartRateSample.quantity.doubleValue(for: .count().unitDivided(by: .minute())))) BPM") + .font(.title) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + } + } + } else { + Text("Invalid Sample") + .italic() + } + } + + + init(_ bloodPressureSample: HKCorrelation, heartRate heartRateSample: HKQuantitySample? = nil) { + self.bloodPressureSample = bloodPressureSample + self.heartRateSample = heartRateSample + } +} + + +#if DEBUG +#Preview { + BloodPressureMeasurementLabel(.mockBloodPressureSample, heartRate: .mockHeartRateSample) +} +#endif diff --git a/Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift b/Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift new file mode 100644 index 0000000..c48e0ea --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/CloseButtonLayer.swift @@ -0,0 +1,45 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +import SwiftUI + + +struct CloseButtonLayer: View { + @Environment(\.dismiss) private var dismiss + @Binding private var viewState: ViewState + + + var body: some View { + HStack { + Button( + action: { + dismiss() + }, + label: { + Text("Close", comment: "For closing sheets.") + .foregroundStyle(Color.accentColor) + } + ) + .buttonStyle(PlainButtonStyle()) + .disabled(viewState != .idle) + + Spacer() + } + .padding() + } + + + init(viewState: Binding) { + self._viewState = viewState + } +} + +#Preview { + CloseButtonLayer(viewState: .constant(.idle)) +} diff --git a/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift b/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift new file mode 100644 index 0000000..e1a730f --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift @@ -0,0 +1,66 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +import SpeziDevices +import SwiftUI + + +struct DiscardButton: View { + @Environment(\.dismiss) var dismiss + @Binding var viewState: ViewState + + + var body: some View { + Button { + dismiss() + } label: { + Text("Discard") + .foregroundStyle(viewState == .idle ? Color.red : Color.gray) + } + .disabled(viewState != .idle) + } +} + + +struct ConfirmMeasurementButton: View { + private let confirm: () async throws -> Void + + @ScaledMetric private var buttonHeight: CGFloat = 38 + @Binding var viewState: ViewState + + var body: some View { + VStack { + AsyncButton(state: $viewState, action: confirm) { + Text("Save") + .frame(maxWidth: .infinity, maxHeight: buttonHeight) + .font(.title2) + .bold() + } + .buttonStyle(.borderedProminent) + + DiscardButton(viewState: $viewState) + .padding(.top, 10) + } + .padding() + } + + init(viewState: Binding, confirm: @escaping () async throws -> Void) { + self._viewState = viewState + self.confirm = confirm + } +} + + +#if DEBUG +#Preview { + ConfirmMeasurementButton(viewState: .constant(.idle)) { + print("Save") + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift new file mode 100644 index 0000000..088b928 --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift @@ -0,0 +1,55 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +#if DEBUG +@_spi(TestingSupport) +#endif +import SpeziDevices +import SwiftUI + + +struct MeasurementLayer: View { + private let measurement: ProcessedHealthMeasurement + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + var body: some View { + VStack(spacing: 15) { + switch measurement { + case let .weight(sample): + WeightMeasurementLabel(sample) + case let .bloodPressure(bloodPressure, heartRate): + BloodPressureMeasurementLabel(bloodPressure, heartRate: heartRate) + } + + if dynamicTypeSize < .accessibility4 { + Text("Measurement Recorded") + .font(.title3) + .foregroundStyle(.secondary) + } + } + .multilineTextAlignment(.center) + } + + + init(measurement: ProcessedHealthMeasurement) { + self.measurement = measurement + } +} + + +#if DEBUG +#Preview { + MeasurementLayer(measurement: .weight(.mockWeighSample)) +} + +#Preview { + MeasurementLayer(measurement: .bloodPressure(.mockBloodPressureSample, heartRate: .mockHeartRateSample)) +} +#endif diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedView.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedView.swift new file mode 100644 index 0000000..197b004 --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementRecordedView.swift @@ -0,0 +1,89 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +#if DEBUG +@_spi(TestingSupport) +#endif +import SpeziDevices +import SwiftUI + + +struct MeasurementRecordedView: View { // TODO: Sheet! + private let measurement: ProcessedHealthMeasurement + + @Environment(HealthMeasurements.self) private var measurements + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @State private var viewState = ViewState.idle + + + private var dynamicDetents: PresentationDetent { + switch dynamicTypeSize { + case .xSmall, .small: + return .fraction(0.35) + case .medium, .large: + return .fraction(0.45) + case .xLarge, .xxLarge, .xxxLarge: + return .fraction(0.65) + case .accessibility1, .accessibility2, .accessibility3, .accessibility4, .accessibility5: + return .large + default: + return .fraction(0.45) + } + } + + + var body: some View { + NavigationStack { + VStack { + MeasurementLayer(measurement: measurement) + Spacer() + ConfirmMeasurementButton(viewState: $viewState) { + try await measurements.saveMeasurement() + } + } + .viewStateAlert(state: $viewState) + .interactiveDismissDisabled(viewState != .idle) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + CloseButtonLayer(viewState: $viewState) + .disabled(viewState != .idle) + } + } + } + .presentationDetents([dynamicDetents]) + } + + + init(measurement: ProcessedHealthMeasurement) { + self.measurement = measurement + } +} + + +#if DEBUG +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + MeasurementRecordedView(measurement: .weight(.mockWeighSample)) + } + .previewWith(standard: TestMeasurementStandard()) { + HealthMeasurements() + } +} + +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + MeasurementRecordedView(measurement: .bloodPressure(.mockBloodPressureSample, heartRate: .mockHeartRateSample)) + } + .previewWith(standard: TestMeasurementStandard()) { + HealthMeasurements() + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift b/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift new file mode 100644 index 0000000..a1d0496 --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift @@ -0,0 +1,38 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +#if DEBUG +@_spi(TestingSupport) +#endif +import SpeziDevices +import SwiftUI + + +struct WeightMeasurementLabel: View { + private let sample: HKQuantitySample + + @ScaledMetric private var measurementTextSize: CGFloat = 60 + + var body: some View { + Text(sample.quantity.description) + .font(.system(size: measurementTextSize, weight: .bold, design: .rounded)) + } + + + init(_ sample: HKQuantitySample) { + self.sample = sample + } +} + + +#if DEBUG +#Preview { + WeightMeasurementLabel(.mockWeighSample) +} +#endif diff --git a/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift b/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift index 74322f8..7d4ed9b 100644 --- a/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift +++ b/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift @@ -6,6 +6,9 @@ // SPDX-License-Identifier: MIT // +#if DEBUG +@_spi(TestingSupport) +#endif import SpeziDevices import SwiftUI diff --git a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift index 1d2fc26..e0e3708 100644 --- a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift +++ b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift @@ -7,6 +7,9 @@ // import SpeziBluetooth +#if DEBUG +@_spi(TestingSupport) +#endif import SpeziDevices import SpeziViews import SwiftUI diff --git a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift index 426cc3f..4d11758 100644 --- a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift @@ -7,6 +7,9 @@ // import ACarousel +#if DEBUG +@_spi(TestingSupport) +#endif import SpeziDevices import SpeziViews import SwiftUI diff --git a/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift index c4ddb71..d756e66 100644 --- a/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift +++ b/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift @@ -6,6 +6,9 @@ // SPDX-License-Identifier: MIT // +#if DEBUG +@_spi(TestingSupport) +#endif import SpeziDevices import SwiftUI diff --git a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings index 40b2842..4a8fc6e 100644 --- a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings +++ b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings @@ -21,6 +21,19 @@ } } }, + "%lld BPM" : { + + }, + "%lld/%lld mmHg" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld/%2$lld mmHg" + } + } + } + }, "Accessory Paired" : { "localizations" : { "en" : { @@ -131,6 +144,9 @@ } } }, + "Close" : { + "comment" : "For closing sheets." + }, "Connected" : { "localizations" : { "en" : { @@ -170,6 +186,9 @@ } } } + }, + "Discard" : { + }, "Disconnecting" : { "localizations" : { @@ -290,6 +309,9 @@ } } } + }, + "Invalid Sample" : { + }, "Make sure to to remove the device from the Bluetooth settings to fully unpair the device." : { "localizations" : { @@ -300,6 +322,9 @@ } } } + }, + "Measurement Recorded" : { + }, "Model" : { "localizations" : { @@ -430,6 +455,9 @@ } } } + }, + "Save" : { + }, "Synchronizing ..." : { "localizations" : { diff --git a/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift b/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift index 049161e..59626ff 100644 --- a/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift +++ b/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift @@ -8,6 +8,9 @@ import SpeziBluetooth +#if DEBUG +@_spi(TestingSupport) +#endif import SpeziDevices import SpeziViews import SwiftUI diff --git a/Sources/SpeziDevicesUI/Testing/MockDevice.swift b/Sources/SpeziDevicesUI/Testing/MockDevice.swift deleted file mode 100644 index 0b37f05..0000000 --- a/Sources/SpeziDevicesUI/Testing/MockDevice.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// 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 -@_spi(TestingSupport) import SpeziBluetooth -import SpeziBluetoothServices -import SpeziDevices - - -#if DEBUG -final class MockDevice: PairableDevice, Identifiable { - @DeviceState(\.id) var id - @DeviceState(\.name) var name - @DeviceState(\.state) var state - @DeviceState(\.advertisementData) var advertisementData - @DeviceState(\.discarded) var discarded - - @DeviceAction(\.connect) var connect - @DeviceAction(\.disconnect) var disconnect - - - @Service var deviceInformation = DeviceInformationService() - - let pairing = PairingContinuation() - var isInPairingMode = false // TODO: control - - // TODO: mandatory setup? -} - - -extension MockDevice { - static func createMockDevice(name: String = "Mock Device", state: PeripheralState = .disconnected) -> MockDevice { - let device = MockDevice() - - device.deviceInformation.$manufacturerName.inject("Mock Company") - device.deviceInformation.$modelNumber.inject("MD1") - device.deviceInformation.$hardwareRevision.inject("2") - device.deviceInformation.$firmwareRevision.inject("1.0") - - device.$id.inject(UUID()) - device.$name.inject(name) - device.$state.inject(state) - - device.$connect.inject { @MainActor [weak device] in - device?.$state.inject(.connecting) - // TODO: await device?.handleStateChange(.connecting) - - try? await Task.sleep(for: .seconds(1)) - - device?.$state.inject(.connected) - // TODO: await device?.handleStateChange(.connected) - } - - device.$disconnect.inject { @MainActor [weak device] in - device?.$state.inject(.disconnected) - // TODO: await device?.handleStateChange(.disconnected) - } - - return device - } -} -#endif diff --git a/Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift b/Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift new file mode 100644 index 0000000..a1c4c78 --- /dev/null +++ b/Sources/SpeziDevicesUI/Testing/TestMeasurementStandard.swift @@ -0,0 +1,20 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +import Spezi +import SpeziDevices + + +#if DEBUG || TEST +actor TestMeasurementStandard: Standard, HealthMeasurementsConstraint { + func addMeasurement(sample: HKSample) async throws { + print("Adding sample \(sample)") + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift b/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift deleted file mode 100644 index 9c61ea0..0000000 --- a/Sources/SpeziDevicesUI/Tips/ConfigureTipKit.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// 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 Spezi -@_spi(TestingSupport) import SpeziFoundation -import TipKit - - -class ConfigureTipKit: Module, DefaultInitializable { // TODO: move to SpeziViews! - @Application(\.logger) private var logger - - - required init() {} - - func configure() { - if RuntimeConfig.testingTips || ProcessInfo.processInfo.isPreviewSimulator { - Tips.showAllTipsForTesting() - } - do { - try Tips.configure() - } catch { - logger.error("Failed to configure TipKit: \(error)") - } - } -} - - -extension RuntimeConfig { - /// Enable testing tips - static let testingTips = CommandLine.arguments.contains("--testTips") -}