From 9e3715d370172ee4db172be87f809ec2e63f88a7 Mon Sep 17 00:00:00 2001 From: nriedman <108841122+nriedman@users.noreply.github.com> Date: Wed, 22 May 2024 11:41:11 -0700 Subject: [PATCH] Feature/weightscale_bluetooth (#6) # Connect to weight scale, Save and display recorded measurements ## :recycle: Current situation & Problem Currently, the application is unable to connect to the weight scale, record weight measurements, and save them to cloud storage. ## :gear: Release Notes - Application now automatically connects to nearest device advertising the Weight Scale Service as defined in the Bluetooth LE protocol. - If the device is new, the application will prompt the user to pair the device. - New measurements are managed by the MeasurementManager class. - When a new weight measurement is recorded, a sheet will appear prompting the user to confirm it, and either save or discard the new measurement. This will appear over any tab of the home view, wherever the user happens to be at the time. - If the user selects save, the measurement is converted to an Apple HealthKit HKQuantitySample, then uploaded to cloud storage (Firestore) as a FHIR Observation via HealthkitOnFHIR. - Also edited the Github action workflow for building and testing to fix a bug preventing previous PR's from passing. This related to the test runs not being signed into a Google Firebase account. ## :books: Documentation More thorough in-line documentation will be included along with testing in the next PR. ## :white_check_mark: Testing UI Tests will be implemented in the next PR. ### Code of Conduct & Contributing Guidelines By submitting creating this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md): - [X] I agree to follow the [Code of Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md). --------- Co-authored-by: Paul Schmiedmayer --- .github/workflows/build-and-test.yml | 5 +- ENGAGEHF.xcodeproj/project.pbxproj | 114 ++++++++- .../xcshareddata/swiftpm/Package.resolved | 124 +++++++--- .../Characteristics/WeightMeasurement.swift | 106 ++++++++ .../Characteristics/WeightScaleFeature.swift | 76 ++++++ .../WeightScale/WeightScaleDevice.swift | 47 ++++ .../WeightScale/WeightScaleService.swift | 28 +++ ENGAGEHF/Bluetooth/MeasurementManager.swift | 229 ++++++++++++++++++ .../Views/ConfirmMeasurementButton.swift | 65 +++++ .../Bluetooth/Views/MeasurementLayer.swift | 51 ++++ .../Views/MeasurementRecordedView.swift | 72 ++++++ ENGAGEHF/Bluetooth/Views/ViewElements.swift | 38 +++ ENGAGEHF/ENGAGEHF.swift | 2 +- ENGAGEHF/ENGAGEHFDelegate.swift | 6 + ENGAGEHF/ENGAGEHFStandard.swift | 2 +- ENGAGEHF/Home.swift | 32 ++- ENGAGEHF/Resources/Localizable.xcstrings | 15 ++ ENGAGEHF/Supporting Files/Info.plist | 4 +- ENGAGEHFUITests/OnboardingUITests.swift | 1 + firebase.json | 14 +- 20 files changed, 982 insertions(+), 49 deletions(-) create mode 100644 ENGAGEHF/Bluetooth/Devices/WeightScale/Characteristics/WeightMeasurement.swift create mode 100644 ENGAGEHF/Bluetooth/Devices/WeightScale/Characteristics/WeightScaleFeature.swift create mode 100644 ENGAGEHF/Bluetooth/Devices/WeightScale/WeightScaleDevice.swift create mode 100644 ENGAGEHF/Bluetooth/Devices/WeightScale/WeightScaleService.swift create mode 100644 ENGAGEHF/Bluetooth/MeasurementManager.swift create mode 100644 ENGAGEHF/Bluetooth/Views/ConfirmMeasurementButton.swift create mode 100644 ENGAGEHF/Bluetooth/Views/MeasurementLayer.swift create mode 100644 ENGAGEHF/Bluetooth/Views/MeasurementRecordedView.swift create mode 100644 ENGAGEHF/Bluetooth/Views/ViewElements.swift diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index beaf6c8e..a1a9ed44 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -39,7 +39,10 @@ jobs: runsonlabels: '["macOS", "self-hosted"]' setupSimulators: true setupfirebaseemulator: true - customcommand: "npm install --previx ./functions && firebase emulators:exec --import=./firebase 'fastlane test'" + fastlanelane: test + firebaseemulatorimport: ./firebase + secrets: + GOOGLE_APPLICATION_CREDENTIALS_BASE64: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS_BASE64 }} uploadcoveragereport: name: Upload Coverage Report needs: buildandtest diff --git a/ENGAGEHF.xcodeproj/project.pbxproj b/ENGAGEHF.xcodeproj/project.pbxproj index f552e2c5..5a761ac4 100644 --- a/ENGAGEHF.xcodeproj/project.pbxproj +++ b/ENGAGEHF.xcodeproj/project.pbxproj @@ -47,6 +47,14 @@ 2FE5DC9C29EDD9EF004B9AB4 /* XCTHealthKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2FE5DC9B29EDD9EF004B9AB4 /* XCTHealthKit */; }; 2FE5DCB129EE6107004B9AB4 /* AccountOnboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DCAC29EE6107004B9AB4 /* AccountOnboarding.swift */; }; 2FF53D8D2A8729D600042B76 /* ENGAGEHFStandard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF53D8C2A8729D600042B76 /* ENGAGEHFStandard.swift */; }; + 4D052DB82BE07892006A784E /* MeasurementManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D052DB72BE07892006A784E /* MeasurementManager.swift */; }; + 4D19ED012BE5CAFC00CDBAA8 /* MeasurementRecordedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D19ED002BE5CAFC00CDBAA8 /* MeasurementRecordedView.swift */; }; + 4D49AAFE2BC9D50400C77310 /* BluetoothServices in Frameworks */ = {isa = PBXBuildFile; productRef = 4D49AAFD2BC9D50400C77310 /* BluetoothServices */; }; + 4D49AB002BC9D50400C77310 /* BluetoothViews in Frameworks */ = {isa = PBXBuildFile; productRef = 4D49AAFF2BC9D50400C77310 /* BluetoothViews */; }; + 4D49AB022BC9D50400C77310 /* SpeziBluetooth in Frameworks */ = {isa = PBXBuildFile; productRef = 4D49AB012BC9D50400C77310 /* SpeziBluetooth */; }; + 4D49AB062BC9D56900C77310 /* WeightScaleDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D49AB052BC9D56900C77310 /* WeightScaleDevice.swift */; }; + 4D49AB0D2BC9DF9100C77310 /* WeightScaleFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D49AB0C2BC9DF9100C77310 /* WeightScaleFeature.swift */; }; + 4D49AB0F2BC9DF9B00C77310 /* WeightMeasurement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D49AB0E2BC9DF9B00C77310 /* WeightMeasurement.swift */; }; 4D4AA0A52BC5E43E00676489 /* OnboardingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4AA0A42BC5E43E00676489 /* OnboardingUITests.swift */; }; 4DB025CA2BBE3A59002D2545 /* HomeViewUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB025C92BBE3A59002D2545 /* HomeViewUITests.swift */; }; 4DB025D52BBF2E08002D2545 /* Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB025D42BBF2E08002D2545 /* Dashboard.swift */; }; @@ -54,6 +62,10 @@ 4DBDD3442BBFAD64001FB0CA /* InvitationCodeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DBDD3432BBFAD64001FB0CA /* InvitationCodeError.swift */; }; 4DBDD3462BBFAE2D001FB0CA /* InvitationCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DBDD3452BBFAE2D001FB0CA /* InvitationCodeView.swift */; }; 4DBDD3482BC073EF001FB0CA /* FirebaseFunctions in Frameworks */ = {isa = PBXBuildFile; productRef = 4DBDD3472BC073EF001FB0CA /* FirebaseFunctions */; }; + 4DDFC76E2BFAA4AE002B07A1 /* WeightScaleService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDFC76D2BFAA4AE002B07A1 /* WeightScaleService.swift */; }; + 4DDFC7702BFAEAD7002B07A1 /* ConfirmMeasurementButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDFC76F2BFAEAD7002B07A1 /* ConfirmMeasurementButton.swift */; }; + 4DDFC7762BFB46FF002B07A1 /* MeasurementLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDFC7752BFB46FF002B07A1 /* MeasurementLayer.swift */; }; + 4DDFC7792BFB4E7D002B07A1 /* ViewElements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDFC7782BFB4E7D002B07A1 /* ViewElements.swift */; }; 56E708352BB06B7100B08F0A /* SpeziLicense in Frameworks */ = {isa = PBXBuildFile; productRef = 56E708342BB06B7100B08F0A /* SpeziLicense */; }; 56E7083B2BB06F6F00B08F0A /* SwiftPackageList in Frameworks */ = {isa = PBXBuildFile; productRef = 56E7083A2BB06F6F00B08F0A /* SwiftPackageList */; }; 653A2551283387FE005D4D48 /* ENGAGEHF.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653A2550283387FE005D4D48 /* ENGAGEHF.swift */; }; @@ -112,12 +124,21 @@ 2FE5DC5529EDD811004B9AB4 /* SocialSupportQuestionnaire.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SocialSupportQuestionnaire.json; sourceTree = ""; }; 2FE5DCAC29EE6107004B9AB4 /* AccountOnboarding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountOnboarding.swift; sourceTree = ""; }; 2FF53D8C2A8729D600042B76 /* ENGAGEHFStandard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ENGAGEHFStandard.swift; sourceTree = ""; }; + 4D052DB72BE07892006A784E /* MeasurementManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementManager.swift; sourceTree = ""; }; + 4D19ED002BE5CAFC00CDBAA8 /* MeasurementRecordedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementRecordedView.swift; sourceTree = ""; }; + 4D49AB052BC9D56900C77310 /* WeightScaleDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeightScaleDevice.swift; sourceTree = ""; }; + 4D49AB0C2BC9DF9100C77310 /* WeightScaleFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeightScaleFeature.swift; sourceTree = ""; }; + 4D49AB0E2BC9DF9B00C77310 /* WeightMeasurement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeightMeasurement.swift; sourceTree = ""; }; 4D4AA0A42BC5E43E00676489 /* OnboardingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingUITests.swift; sourceTree = ""; }; 4DB025C92BBE3A59002D2545 /* HomeViewUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewUITests.swift; sourceTree = ""; }; 4DB025D42BBF2E08002D2545 /* Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dashboard.swift; sourceTree = ""; }; 4DB025D72BBF2EEC002D2545 /* Greeting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Greeting.swift; sourceTree = ""; }; 4DBDD3432BBFAD64001FB0CA /* InvitationCodeError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitationCodeError.swift; sourceTree = ""; }; 4DBDD3452BBFAE2D001FB0CA /* InvitationCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitationCodeView.swift; sourceTree = ""; }; + 4DDFC76D2BFAA4AE002B07A1 /* WeightScaleService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeightScaleService.swift; sourceTree = ""; }; + 4DDFC76F2BFAEAD7002B07A1 /* ConfirmMeasurementButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmMeasurementButton.swift; sourceTree = ""; }; + 4DDFC7752BFB46FF002B07A1 /* MeasurementLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementLayer.swift; sourceTree = ""; }; + 4DDFC7782BFB4E7D002B07A1 /* ViewElements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewElements.swift; sourceTree = ""; }; 653A254D283387FE005D4D48 /* ENGAGEHF.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ENGAGEHF.app; sourceTree = BUILT_PRODUCTS_DIR; }; 653A2550283387FE005D4D48 /* ENGAGEHF.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ENGAGEHF.swift; sourceTree = ""; }; 653A255428338800005D4D48 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -146,6 +167,7 @@ A9D83F962B083794000D0C78 /* SpeziFirebaseAccountStorage in Frameworks */, 2FB099B62A875E2B00B20952 /* HealthKitOnFHIR in Frameworks */, 56E7083B2BB06F6F00B08F0A /* SwiftPackageList in Frameworks */, + 4D49AB022BC9D50400C77310 /* SpeziBluetooth in Frameworks */, 56E708352BB06B7100B08F0A /* SpeziLicense in Frameworks */, 2FE5DC8A29EDD972004B9AB4 /* SpeziLocalStorage in Frameworks */, 2FE5DC8C29EDD972004B9AB4 /* SpeziSecureStorage in Frameworks */, @@ -153,7 +175,9 @@ A92E4DF02BAA001100AC8DE8 /* OrderedCollections in Frameworks */, 9739A0C62AD7B5730084BEA5 /* FirebaseStorage in Frameworks */, 4DBDD3482BC073EF001FB0CA /* FirebaseFunctions in Frameworks */, + 4D49AB002BC9D50400C77310 /* BluetoothViews in Frameworks */, 2FE5DC7229EDD8D3004B9AB4 /* SpeziHealthKit in Frameworks */, + 4D49AAFE2BC9D50400C77310 /* BluetoothServices in Frameworks */, 2F49B7762980407C00BCB272 /* Spezi in Frameworks */, 2FE5DC8F29EDD980004B9AB4 /* SpeziViews in Frameworks */, 2FE5DC7929EDD8E6004B9AB4 /* SpeziFirestore in Frameworks */, @@ -245,6 +269,54 @@ path = Helper; sourceTree = ""; }; + 4D19ECFC2BE5B9F200CDBAA8 /* Views */ = { + isa = PBXGroup; + children = ( + 4D19ED002BE5CAFC00CDBAA8 /* MeasurementRecordedView.swift */, + 4DDFC76F2BFAEAD7002B07A1 /* ConfirmMeasurementButton.swift */, + 4DDFC7752BFB46FF002B07A1 /* MeasurementLayer.swift */, + 4DDFC7782BFB4E7D002B07A1 /* ViewElements.swift */, + ); + path = Views; + sourceTree = ""; + }; + 4D49AAFB2BC9D46F00C77310 /* Bluetooth */ = { + isa = PBXGroup; + children = ( + 4D19ECFC2BE5B9F200CDBAA8 /* Views */, + 4D49AB142BCF6FC400C77310 /* Devices */, + 4D052DB72BE07892006A784E /* MeasurementManager.swift */, + ); + path = Bluetooth; + sourceTree = ""; + }; + 4D49AB092BC9DA8B00C77310 /* WeightScale */ = { + isa = PBXGroup; + children = ( + 4D49AB0A2BC9DA9300C77310 /* Characteristics */, + 4D49AB052BC9D56900C77310 /* WeightScaleDevice.swift */, + 4DDFC76D2BFAA4AE002B07A1 /* WeightScaleService.swift */, + ); + path = WeightScale; + sourceTree = ""; + }; + 4D49AB0A2BC9DA9300C77310 /* Characteristics */ = { + isa = PBXGroup; + children = ( + 4D49AB0C2BC9DF9100C77310 /* WeightScaleFeature.swift */, + 4D49AB0E2BC9DF9B00C77310 /* WeightMeasurement.swift */, + ); + path = Characteristics; + sourceTree = ""; + }; + 4D49AB142BCF6FC400C77310 /* Devices */ = { + isa = PBXGroup; + children = ( + 4D49AB092BC9DA8B00C77310 /* WeightScale */, + ); + path = Devices; + sourceTree = ""; + }; 4DB025B52BBDE8EC002D2545 /* Dashboard */ = { isa = PBXGroup; children = ( @@ -284,6 +356,7 @@ 2FF53D8C2A8729D600042B76 /* ENGAGEHFStandard.swift */, 2F4E23822989D51F0013F3D9 /* ENGAGEHFTestingSetup.swift */, 2FC975A72978F11A00BA99FE /* Home.swift */, + 4D49AAFB2BC9D46F00C77310 /* Bluetooth */, 4DB025B52BBDE8EC002D2545 /* Dashboard */, A9720E412ABB68B300872D23 /* Account */, 2FE5DC2829EDD398004B9AB4 /* Onboarding */, @@ -373,6 +446,9 @@ 56E708342BB06B7100B08F0A /* SpeziLicense */, 56E7083A2BB06F6F00B08F0A /* SwiftPackageList */, 4DBDD3472BC073EF001FB0CA /* FirebaseFunctions */, + 4D49AAFD2BC9D50400C77310 /* BluetoothServices */, + 4D49AAFF2BC9D50400C77310 /* BluetoothViews */, + 4D49AB012BC9D50400C77310 /* SpeziBluetooth */, ); productName = ENGAGEHF; productReference = 653A254D283387FE005D4D48 /* ENGAGEHF.app */; @@ -468,6 +544,7 @@ A92E4DEE2BAA001100AC8DE8 /* XCRemoteSwiftPackageReference "swift-collections" */, 56E708332BB06B7100B08F0A /* XCRemoteSwiftPackageReference "SpeziLicense" */, 2F66D20D2BB723180010D555 /* XCRemoteSwiftPackageReference "SwiftLint" */, + 4D49AAFC2BC9D50400C77310 /* XCRemoteSwiftPackageReference "SpeziBluetooth" */, ); productRefGroup = 653A254E283387FE005D4D48 /* Products */; projectDirPath = ""; @@ -516,30 +593,39 @@ buildActionMask = 2147483647; files = ( 2FE5DC4129EDD7EE004B9AB4 /* StorageKeys.swift in Sources */, + 4D19ED012BE5CAFC00CDBAA8 /* MeasurementRecordedView.swift in Sources */, 2FE5DCB129EE6107004B9AB4 /* AccountOnboarding.swift in Sources */, 2FE5DC3A29EDD7CA004B9AB4 /* Welcome.swift in Sources */, + 4DDFC76E2BFAA4AE002B07A1 /* WeightScaleService.swift in Sources */, 2FE5DC3829EDD7CA004B9AB4 /* InterestingModules.swift in Sources */, 2FE5DC3529EDD7CA004B9AB4 /* Consent.swift in Sources */, 2FE5DC4529EDD7F2004B9AB4 /* Binding+Negate.swift in Sources */, 4DBDD3442BBFAD64001FB0CA /* InvitationCodeError.swift in Sources */, 2FC975A82978F11A00BA99FE /* Home.swift in Sources */, + 4DDFC7792BFB4E7D002B07A1 /* ViewElements.swift in Sources */, + 4DDFC7762BFB46FF002B07A1 /* MeasurementLayer.swift in Sources */, 4DB025D52BBF2E08002D2545 /* Dashboard.swift in Sources */, A9DFE8A92ABE551400428242 /* AccountButton.swift in Sources */, 2FE5DC3729EDD7CA004B9AB4 /* OnboardingFlow.swift in Sources */, 2FF53D8D2A8729D600042B76 /* ENGAGEHFStandard.swift in Sources */, 2FE5DC4729EDD7F2004B9AB4 /* CodableArray+RawRepresentable.swift in Sources */, + 4D052DB82BE07892006A784E /* MeasurementManager.swift in Sources */, 4DB025D82BBF2EEC002D2545 /* Greeting.swift in Sources */, A9720E432ABB68CC00872D23 /* AccountSetupHeader.swift in Sources */, + 4D49AB0F2BC9DF9B00C77310 /* WeightMeasurement.swift in Sources */, + 4D49AB0D2BC9DF9100C77310 /* WeightScaleFeature.swift in Sources */, 2FE5DC4029EDD7EE004B9AB4 /* FeatureFlags.swift in Sources */, 2FE5DC4629EDD7F2004B9AB4 /* Bundle+Image.swift in Sources */, 2F4E23832989D51F0013F3D9 /* ENGAGEHFTestingSetup.swift in Sources */, 2F5E32BD297E05EA003432F8 /* ENGAGEHFDelegate.swift in Sources */, + 4DDFC7702BFAEAD7002B07A1 /* ConfirmMeasurementButton.swift in Sources */, A9FE7AD02AA39BAB0077B045 /* AccountSheet.swift in Sources */, 653A2551283387FE005D4D48 /* ENGAGEHF.swift in Sources */, 2FE5DC3629EDD7CA004B9AB4 /* HealthKitPermissions.swift in Sources */, 2F65B44E2A3B8B0600A36932 /* NotificationPermissions.swift in Sources */, 2FE5DC2629EDD38A004B9AB4 /* Contacts.swift in Sources */, 4DBDD3462BBFAE2D001FB0CA /* InvitationCodeView.swift in Sources */, + 4D49AB062BC9D56900C77310 /* WeightScaleDevice.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -663,6 +749,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "ENGAGEHF/Supporting Files/Info.plist"; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Application must access bluetooth to connect to data collecting devices and retrieve data from weight scales and blood pressure cuffs."; INFOPLIST_KEY_NSCameraUsageDescription = "This message should never appear. Please adjust this when you start using camera information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect."; INFOPLIST_KEY_NSHealthShareUsageDescription = "The ENGAGE-HF app uses the step count to demonstrate Spezi's integration with HealthKit."; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "The ENGAGE-HF app uses the step count to demonstrate Spezi's integration with HealthKit."; @@ -856,10 +943,11 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 637867499T; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "ENGAGEHF/Supporting Files/Info.plist"; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Application must access bluetooth to connect to data collecting devices and retrieve data from weight scales and blood pressure cuffs."; INFOPLIST_KEY_NSCameraUsageDescription = "This message should never appear. Please adjust this when you start using camera information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect."; INFOPLIST_KEY_NSHealthShareUsageDescription = "The ENGAGE-HF app uses the step count to demonstrate Spezi's integration with HealthKit."; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "The ENGAGE-HF app uses the step count to demonstrate Spezi's integration with HealthKit."; @@ -903,6 +991,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "ENGAGEHF/Supporting Files/Info.plist"; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Application must access bluetooth to connect to data collecting devices and retrieve data from weight scales and blood pressure cuffs."; INFOPLIST_KEY_NSCameraUsageDescription = "This message should never appear. Please adjust this when you start using camera information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect."; INFOPLIST_KEY_NSHealthShareUsageDescription = "The ENGAGE-HF app uses the step count to demonstrate Spezi's integration with HealthKit."; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "The ENGAGE-HF app uses the step count to demonstrate Spezi's integration with HealthKit."; @@ -1149,6 +1238,14 @@ minimumVersion = 0.3.5; }; }; + 4D49AAFC2BC9D50400C77310 /* XCRemoteSwiftPackageReference "SpeziBluetooth" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/StanfordSpezi/SpeziBluetooth.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.2; + }; + }; 5661551B2AB8384200209B80 /* XCRemoteSwiftPackageReference "swift-package-list" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/FelixHerrmann/swift-package-list"; @@ -1279,6 +1376,21 @@ package = 2FE5DC9A29EDD9EF004B9AB4 /* XCRemoteSwiftPackageReference "XCTHealthKit" */; productName = XCTHealthKit; }; + 4D49AAFD2BC9D50400C77310 /* BluetoothServices */ = { + isa = XCSwiftPackageProductDependency; + package = 4D49AAFC2BC9D50400C77310 /* XCRemoteSwiftPackageReference "SpeziBluetooth" */; + productName = BluetoothServices; + }; + 4D49AAFF2BC9D50400C77310 /* BluetoothViews */ = { + isa = XCSwiftPackageProductDependency; + package = 4D49AAFC2BC9D50400C77310 /* XCRemoteSwiftPackageReference "SpeziBluetooth" */; + productName = BluetoothViews; + }; + 4D49AB012BC9D50400C77310 /* SpeziBluetooth */ = { + isa = XCSwiftPackageProductDependency; + package = 4D49AAFC2BC9D50400C77310 /* XCRemoteSwiftPackageReference "SpeziBluetooth" */; + productName = SpeziBluetooth; + }; 4DBDD3472BC073EF001FB0CA /* FirebaseFunctions */ = { isa = XCSwiftPackageProductDependency; package = 2FE5DC9029EDD9C3004B9AB4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/ENGAGEHF.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ENGAGEHF.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b8e180b2..a46434b2 100644 --- a/ENGAGEHF.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ENGAGEHF.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "84060c25a97a757bf3045128a2563021282283c70a9cb6009941a41298deef92", + "originHash" : "7a1ae4cd835f2e84578822a40fc673f29fc7ab26c0416a0079ce8402bbb3da0b", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -10,13 +10,22 @@ "version" : "1.2024011601.1" } }, + { + "identity" : "antlr4", + "kind" : "remoteSourceControl", + "location" : "https://github.com/antlr/antlr4", + "state" : { + "revision" : "7ed420ff2c78d62883875c442d75f32e73bc86c8", + "version" : "4.13.1" + } + }, { "identity" : "app-check", "kind" : "remoteSourceControl", "location" : "https://github.com/google/app-check.git", "state" : { - "revision" : "c218c2054299b15ae577e818bbba16084d3eabe6", - "version" : "10.18.2" + "revision" : "076b241a625e25eac22f8849be256dfb960fcdfe", + "version" : "10.19.1" } }, { @@ -33,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", "state" : { - "revision" : "7892a123f7e8d0fe62f9f03728b17bbd4f94df5c", - "version" : "1.8.1" + "revision" : "c9c3df6ab812de32bae61fc0cd1bf6d45170ebf0", + "version" : "1.8.2" } }, { @@ -51,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk.git", "state" : { - "revision" : "888f0b6026e2441a69e3ee2ad5293c7a92031e62", - "version" : "10.23.1" + "revision" : "9d17b500cd98d9a7009751ad62f802e152e97021", + "version" : "10.26.0" } }, { @@ -60,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "c7a5917ebe48d69f421aadf154ef3969c8b7f12d", - "version" : "10.23.1" + "revision" : "16244d177c4e989f87b25e9db1012b382cfedc55", + "version" : "10.25.0" } }, { @@ -78,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "26c898aed8bed13b8a63057ee26500abbbcb8d55", - "version" : "7.13.1" + "revision" : "57a1d307f42df690fdef2637f3e5b776da02aad6", + "version" : "7.13.3" } }, { @@ -96,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/gtm-session-fetcher.git", "state" : { - "revision" : "9534039303015a84837090d20fa21cae6e5eadb6", - "version" : "3.3.2" + "revision" : "0382ca27f22fb3494cf657d8dc356dc282cd1193", + "version" : "3.4.1" } }, { @@ -105,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordBDHG/HealthKitOnFHIR.git", "state" : { - "revision" : "d6ceecf11800d73fed0c6ce33717f3dc71a44bd7", - "version" : "0.2.7" + "revision" : "418929f315f37e6d9c8f30f40030bc65b9cc47c9", + "version" : "0.2.8" } }, { @@ -123,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/leveldb.git", "state" : { - "revision" : "43aaef65e0c665daadf848761d560e446d350d3d", - "version" : "1.22.4" + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" } }, { @@ -159,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordBDHG/ResearchKitOnFHIR", "state" : { - "revision" : "7cd02fe3eee061b8cfbb32d272715af8838b978e", - "version" : "1.2.0" + "revision" : "cdb24dd5607d5a63aaf3a3597c98122189cb548e", + "version" : "1.3.0" } }, { @@ -168,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/SourceKitten.git", "state" : { - "revision" : "b6dc09ee51dfb0c66e042d2328c017483a1a5d56", - "version" : "0.34.1" + "revision" : "fd4df99170f5e9d7cf9aa8312aa8506e0e7a44e7", + "version" : "0.35.0" } }, { @@ -186,8 +195,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziAccount.git", "state" : { - "revision" : "cb9441e5fe9ca31a17be2507d03817a080e63e9d", - "version" : "1.2.2" + "revision" : "2de07209430fe7b13c44790eab948b30482fcb9d", + "version" : "1.2.4" + } + }, + { + "identity" : "spezibluetooth", + "kind" : "remoteSourceControl", + "location" : "https://github.com/StanfordSpezi/SpeziBluetooth.git", + "state" : { + "revision" : "8e94fc71720ef3fcf7f5d9dba9eef603c5151d7a", + "version" : "1.3.0" } }, { @@ -199,6 +217,15 @@ "version" : "1.0.0" } }, + { + "identity" : "spezifileformats", + "kind" : "remoteSourceControl", + "location" : "https://github.com/StanfordSpezi/SpeziFileFormats", + "state" : { + "revision" : "017ea997f4f6128b15e8b8d4aac979cb7a822e74", + "version" : "1.2.0" + } + }, { "identity" : "spezifirebase", "kind" : "remoteSourceControl", @@ -211,7 +238,7 @@ { "identity" : "spezifoundation", "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziFoundation.git", + "location" : "https://github.com/StanfordSpezi/SpeziFoundation", "state" : { "revision" : "01af5b91a54f30ddd121258e81aff2ddc2a99ff9", "version" : "1.0.4" @@ -240,8 +267,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziOnboarding", "state" : { - "revision" : "4971a82e94996ce0c3d8ecf64fdeec874a1f20d6", - "version" : "1.1.1" + "revision" : "8d6dda3501720a1952573439b21a503cbecd9e0f", + "version" : "1.2.0" } }, { @@ -274,10 +301,19 @@ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", + "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", - "version" : "1.2.3" + "revision" : "46989693916f56d1186bd59ac15124caef896560", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "cd142fd2f64be2100422d658e7411e39489da985", + "version" : "1.2.0" } }, { @@ -289,6 +325,15 @@ "version" : "1.1.0" } }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "359c461e5561d22c6334828806cc25d759ca7aa6", + "version" : "2.65.0" + } + }, { "identity" : "swift-package-list", "kind" : "remoteSourceControl", @@ -312,8 +357,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax.git", "state" : { - "revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036", - "version" : "509.0.2" + "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", + "version" : "510.0.2" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "f9266c85189c2751589a50ea5aec72799797e471", + "version" : "1.3.0" } }, { @@ -321,8 +375,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/realm/SwiftLint.git", "state" : { - "revision" : "f17a4f9dfb6a6afb0408426354e4180daaf49cee", - "version" : "0.54.0" + "revision" : "b515723b16eba33f15c4677ee65f3fef2ce8c255", + "version" : "0.55.1" } }, { @@ -375,8 +429,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams.git", "state" : { - "revision" : "8a835d918245ca22f36663dd3862138805d7f707", - "version" : "5.1.0" + "revision" : "9234124cff5e22e178988c18d8b95a8ae8007f76", + "version" : "5.1.2" } } ], diff --git a/ENGAGEHF/Bluetooth/Devices/WeightScale/Characteristics/WeightMeasurement.swift b/ENGAGEHF/Bluetooth/Devices/WeightScale/Characteristics/WeightMeasurement.swift new file mode 100644 index 00000000..8cca40a5 --- /dev/null +++ b/ENGAGEHF/Bluetooth/Devices/WeightScale/Characteristics/WeightMeasurement.swift @@ -0,0 +1,106 @@ +// +// 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 BluetoothServices +import ByteCoding +import Foundation +import NIOCore + + +enum WeightUnits: String, Equatable { + case metric = "kg" + case imperial = "lb" +} + + +struct WeightMeasurement: Equatable { + // Flags + let units: WeightUnits + let timeStampPresent: Bool + let userIDPresent: Bool + let heightBMIPresent: Bool + + // Units: + // Kilograms with a resolution of 0.005 + // Pounds with a resolution of 0.01 + let weight: UInt16 + + // Only present when corresponding flag is true + let timeStamp: DateTime? + let bmi: UInt16? + let height: UInt16? + let userID: UInt8? +} + + +extension WeightMeasurement: ByteDecodable { + init?(from byteBuffer: inout NIOCore.ByteBuffer, preferredEndianness endianness: NIOCore.Endianness) { + guard byteBuffer.readableBytes >= 11 else { + return nil + } + + // Decode fields as described in the manual + guard let flagBits = UInt8(from: &byteBuffer, preferredEndianness: endianness), + let weight = UInt16(from: &byteBuffer, preferredEndianness: endianness) else { + return nil + } + + // Extract flags + let timeStampFlag: Bool = ((flagBits >> 1) & 0b1) != 0 + let userIDFlag: Bool = ((flagBits >> 2) & 0b1) != 0 + let heightBMIFlag: Bool = ((flagBits >> 3) & 0b1) != 0 + + // Get the time stamp + if timeStampFlag { + guard let timeStamp = DateTime(from: &byteBuffer, preferredEndianness: endianness) else { + return nil + } + + self.timeStamp = timeStamp + } else { + self.timeStamp = nil + } + + if userIDFlag { + guard let userID = UInt8(from: &byteBuffer, preferredEndianness: endianness) else { + return nil + } + + self.userID = userID + } else { + self.userID = nil + } + + if heightBMIFlag { + guard let bmi = UInt16(from: &byteBuffer), + let height = UInt16(from: &byteBuffer) else { + return nil + } + + self.bmi = bmi + self.height = height + } else { + self.bmi = nil + self.height = nil + } + + self.units = { + if (flagBits & 1) == 1 { + return .imperial + } else { + return .metric + } + }() + + + self.timeStampPresent = timeStampFlag + self.heightBMIPresent = heightBMIFlag + self.userIDPresent = userIDFlag + self.weight = weight + } +} diff --git a/ENGAGEHF/Bluetooth/Devices/WeightScale/Characteristics/WeightScaleFeature.swift b/ENGAGEHF/Bluetooth/Devices/WeightScale/Characteristics/WeightScaleFeature.swift new file mode 100644 index 00000000..900990ba --- /dev/null +++ b/ENGAGEHF/Bluetooth/Devices/WeightScale/Characteristics/WeightScaleFeature.swift @@ -0,0 +1,76 @@ +// +// 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 ByteCoding +import Foundation +import NIOCore + + +// Weight resolutions in kgs / lbs as defined in the manual +enum WeightMeasurementResolution: UInt8, Equatable { + case unspecified = 0 + case gradeOne = 1 // 0.5 kg or 1 lb + case gradeTwo = 2 // 0.2 kg or .5 lb + case gradeThree = 3 // 0.1 kg or 0.2 lb + case gradeFour = 4 // 0.05 kg or 0.1 lb + case gradeFive = 5 // 0.02 kg or 0.05 lb + case gradeSix = 6 // 0.01 kg or 0.02 lb + case gradeSeven = 7 // 0.005 kg or 0.01 lb +} + + +// Height resolutions in inches / meters as defined in the manual +enum HeightMeasurementResolution: UInt8, Equatable { + case unspecified = 0 + case gradeOne = 1 // 0.01 meter or 1 inch + case gradeTwo = 2 // 0.005 meter or 0.5 inch + case gradeThree = 3 // 0.001 meter or 0.1 inch +} + + +struct WeightScaleFeature { + let timeStampEnabled: Bool + let supportMultipleUsers: Bool + let supportBMI: Bool + let weightResolution: WeightMeasurementResolution + let heightResolution: HeightMeasurementResolution +} + + +extension WeightScaleFeature: ByteDecodable, Equatable { + init?(from byteBuffer: inout ByteBuffer, preferredEndianness endianness: Endianness) { + guard byteBuffer.readableBytes >= 4 else { + return nil + } + + // Read the 32 bits from byte buffer + guard let allBits = UInt32(from: &byteBuffer) else { + return nil + } + + // Decode the boolean flag bits + let timeStampFlag: Bool = (allBits & (0b1)) != 0 + let supportUsersFlag: Bool = (allBits & (0b1 << 1)) != 0 + let supportBMIFlag: Bool = (allBits & (0b1 << 2)) != 0 + + // Decode the resolution bits + let rawWeightResolution = UInt8((allBits >> 3) & 0b1111) + let rawHeightResolution = UInt8((allBits >> 7) & 0b111) + + guard let weightResolution = WeightMeasurementResolution(rawValue: rawWeightResolution), + let heightResolution = HeightMeasurementResolution(rawValue: rawHeightResolution) else { + return nil + } + + self.timeStampEnabled = timeStampFlag + self.supportMultipleUsers = supportUsersFlag + self.supportBMI = supportBMIFlag + self.weightResolution = weightResolution + self.heightResolution = heightResolution + } +} diff --git a/ENGAGEHF/Bluetooth/Devices/WeightScale/WeightScaleDevice.swift b/ENGAGEHF/Bluetooth/Devices/WeightScale/WeightScaleDevice.swift new file mode 100644 index 00000000..924baa85 --- /dev/null +++ b/ENGAGEHF/Bluetooth/Devices/WeightScale/WeightScaleDevice.swift @@ -0,0 +1,47 @@ +// +// 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 BluetoothServices +import Foundation +import SpeziBluetooth + + +// +// A bluetooth peripheral representing a Weight Scale +// +// On new measurement, loads the measurement into the MeasurementManager +// as a HealthKit HKQuantitySample +// +class WeightScaleDevice: BluetoothDevice, Identifiable { + @DeviceState(\.id) var id: UUID + @DeviceState(\.name)var name: String? + @DeviceState(\.state) var state: PeripheralState + + @Service var deviceInformation = DeviceInformationService() + @Service var service = WeightScaleService() + + @DeviceAction(\.connect) var connect + @DeviceAction(\.disconnect) var disconnect + + + required init() { + service.$weightMeasurement.onChange(perform: processMeasurement) + } + + private func processMeasurement(_ measurement: WeightMeasurement) { + if !service.$weightMeasurement.isPresent { + return + } + + MeasurementManager.manager.deviceInformation = deviceInformation + MeasurementManager.manager.weightScaleParams = service.weightScaleFeature + MeasurementManager.manager.deviceName = name + + MeasurementManager.manager.loadMeasurement(measurement) + } +} diff --git a/ENGAGEHF/Bluetooth/Devices/WeightScale/WeightScaleService.swift b/ENGAGEHF/Bluetooth/Devices/WeightScale/WeightScaleService.swift new file mode 100644 index 00000000..945dfc53 --- /dev/null +++ b/ENGAGEHF/Bluetooth/Devices/WeightScale/WeightScaleService.swift @@ -0,0 +1,28 @@ +// +// 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 BluetoothServices +import class CoreBluetooth.CBUUID +import SpeziBluetooth + + +// The primary Weight Scale Service +// Note: Access properties: R: read, W: write, N: notify +class WeightScaleService: BluetoothService { + static var id = CBUUID(string: "181D") + + // 2 characteristics as defined in the manual: + + // Characteristic 1: Weight Scale Feature, R + @Characteristic(id: "2A9E") var weightScaleFeature: WeightScaleFeature? + + // Characteristic 2: Weight Measurement, N + @Characteristic(id: "2A9D", notify: true) var weightMeasurement: WeightMeasurement? + + init() {} +} diff --git a/ENGAGEHF/Bluetooth/MeasurementManager.swift b/ENGAGEHF/Bluetooth/MeasurementManager.swift new file mode 100644 index 00000000..effb1dbd --- /dev/null +++ b/ENGAGEHF/Bluetooth/MeasurementManager.swift @@ -0,0 +1,229 @@ +// +// 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 BluetoothServices +import FirebaseAuth +import FirebaseCore +import FirebaseFirestore +import Foundation +import HealthKit +import OSLog +import Spezi +@_spi(TestingSupport) import SpeziBluetooth +import SpeziFirestore + + +// Functionality: +// - Store the user's measurement history in an array +// - Watch for changes in the user's blood pressure and weight measurement collections in firebase +// - Convert an incoming measurement into a HKSample and transform to FHIR Observation +// - Save a given measurement to Firebase +@Observable +class MeasurementManager: Module, EnvironmentAccessible { + private static var _manager: MeasurementManager? + static var manager: MeasurementManager { + guard let manager = _manager else { + fatalError("Accessing shared MeasurmentManager before initialized.") + } + return manager + } + + var showSheet: Bool { + get { + newMeasurement != nil + } + set { + if !newValue { + self.newMeasurement = nil + } + } + } + + + @ObservationIgnored @StandardActor private var standard: ENGAGEHFStandard + private let logger = Logger(subsystem: "ENGAGEHF", category: "MeasurementManager") + + var deviceInformation: DeviceInformationService? + var weightScaleParams: WeightScaleFeature? + var deviceName: String? + + var newMeasurement: HKQuantitySample? + + + init() { + MeasurementManager._manager = self + } + + + // Called to reset measurement manager after taking a measurement + func clear() { + self.newMeasurement = nil + self.deviceInformation = nil + self.weightScaleParams = nil + self.deviceName = nil + } + + // Called by WeightScaleDevice on change of WeightMeasurement Characteristic + func loadMeasurement(_ measurement: WeightMeasurement) { + // Convert to HKQuantitySample after downloading from Firestore + self.newMeasurement = convertToHKSample(measurement) + logger.info("Measurement loaded into MeasurementManager: \(measurement.weight)") + } + + // Called by UI Sheet View to save the newMeasurement to firestore + func saveMeasurement() async throws { + if ProcessInfo.processInfo.isPreviewSimulator { + try await Task.sleep(for: .seconds(5)) + return + } + + guard let measurement: HKQuantitySample = self.newMeasurement else { + logger.error("Attempting to save a nil measurement.") + return + } + + logger.info("Saving the following measurement: \(measurement.quantity.description)") + await standard.add(sample: measurement) + + logger.info("Save successful!") + self.clear() + } + + + private func convertToHKSample(_ measurement: WeightMeasurement) -> HKQuantitySample? { + guard let deviceInfo: DeviceInformationService = deviceInformation else { + logger.error("***** Device Information not present *****") + return nil + } + + let device = HKDevice( + name: deviceName, + manufacturer: deviceInfo.manufacturerName, + model: deviceInfo.modelNumber, + hardwareVersion: deviceInfo.hardwareRevision, + firmwareVersion: deviceInfo.firmwareRevision, + softwareVersion: deviceInfo.softwareRevision, + localIdentifier: nil, + udiDeviceIdentifier: nil + ) + + let quantityType = HKQuantityType(.bodyMass) + let units = HKUnit(from: measurement.units.rawValue) + + guard let resolution = getResolutionScalar(for: measurement.units) else { + logger.error("***** Unable to get Resolution Scalar *****") + return nil + } + + let quantity = HKQuantity(unit: units, doubleValue: Double(measurement.weight) * resolution) + let date = getDate(from: measurement) + + return HKQuantitySample( + type: quantityType, + quantity: quantity, + start: date, + end: date, + device: device, + metadata: nil + ) + } + + private func getResolutionScalar(for units: WeightUnits) -> Double? { + guard let scaleParams: WeightScaleFeature = weightScaleParams else { + logger.error("***** Weight Scale Features not present *****") + return nil + } + + let resolution = scaleParams.weightResolution + let isLbs = units == .imperial + + switch resolution { + case .unspecified: return 1 + case .gradeOne: return isLbs ? 1 : 0.1 + case .gradeTwo: return 0.1 + case .gradeThree: return 0.1 + case .gradeFour: return isLbs ? 0.1 : 0.01 + case .gradeFive: return 0.01 + case .gradeSix: return 0.01 + case .gradeSeven: return isLbs ? 0.01 : 0.001 + } + } + + private func getDate(from measurement: WeightMeasurement) -> Date { + guard let dateTime: DateTime = measurement.timeStamp else { + return .now + } + + let year = dateTime.year + let month = dateTime.month + let day = dateTime.day + let hour = dateTime.hours + let minute = dateTime.minutes + let second = dateTime.seconds + + if year == 0, month == .unknown, day == 0 { + logger.info("***** Timestamp unkown, displaying current date *****") + return .now + } + + let dateComponents = DateComponents( + year: year != 0 ? Int(year) : nil, + month: month != .unknown ? Int(month.rawValue) : nil, + day: day != 0 ? Int(day) : nil, + hour: hour != 0 ? Int(hour) : nil, + minute: minute != 0 ? Int(minute) : nil, + second: second != 0 ? Int(second) : nil + ) + + guard let date = Calendar.current.date(from: dateComponents) else { + logger.error("***** Invalid date components, returning current date *****") + return .now + } + + return date + } +} + + +extension MeasurementManager { + // Call in preview simulator wrappers + // Loads a mock measurement to display in preview + func loadMockMeasurement() { + self.deviceName = "Mock Device" + + let devInfo = DeviceInformationService() + devInfo.$manufacturerName.inject("Mock") + devInfo.$modelNumber.inject("42") + devInfo.$hardwareRevision.inject("42") + devInfo.$firmwareRevision.inject("42") + devInfo.$softwareRevision.inject("42") + self.deviceInformation = devInfo + + self.weightScaleParams = WeightScaleFeature( + timeStampEnabled: true, + supportMultipleUsers: true, + supportBMI: true, + weightResolution: .gradeSix, + heightResolution: .gradeThree + ) + + self.loadMeasurement( + WeightMeasurement( + units: .metric, + timeStampPresent: true, + userIDPresent: true, + heightBMIPresent: true, + weight: 8400, + timeStamp: DateTime(hours: 0, minutes: 0, seconds: 0), + bmi: 20, + height: 180, + userID: 42 + ) + ) + } +} diff --git a/ENGAGEHF/Bluetooth/Views/ConfirmMeasurementButton.swift b/ENGAGEHF/Bluetooth/Views/ConfirmMeasurementButton.swift new file mode 100644 index 00000000..18777954 --- /dev/null +++ b/ENGAGEHF/Bluetooth/Views/ConfirmMeasurementButton.swift @@ -0,0 +1,65 @@ +// +// 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 DiscardButton: View { + @Environment(\.dismiss) var dismiss + @Binding var viewState: ViewState + + + var body: some View { + Button( + action: { + dismiss() + }, + label: { + Text("Discard") + .foregroundStyle(viewState == .idle ? Color.red : Color.gray) + } + ) + .disabled(viewState != .idle) + } +} + + +struct ConfirmMeasurementButton: View { + @ScaledMetric private var buttonHeight: CGFloat = 38 + @Binding var viewState: ViewState + + + var body: some View { + VStack { + AsyncButton( + state: $viewState, + action: { + try await MeasurementManager.manager.saveMeasurement() + }, + label: { + Text("Save") + .frame(maxWidth: .infinity, maxHeight: buttonHeight) + .font(.title2) + .bold() + } + ) + .buttonStyle(.borderedProminent) + .viewStateAlert(state: $viewState) + + DiscardButton(viewState: $viewState) + .padding(.top, 10) + } + .padding() + } +} + +#Preview { + @State var viewState = ViewState.idle + return ConfirmMeasurementButton(viewState: $viewState) +} diff --git a/ENGAGEHF/Bluetooth/Views/MeasurementLayer.swift b/ENGAGEHF/Bluetooth/Views/MeasurementLayer.swift new file mode 100644 index 00000000..bf948360 --- /dev/null +++ b/ENGAGEHF/Bluetooth/Views/MeasurementLayer.swift @@ -0,0 +1,51 @@ +// +// 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 SwiftUI + + +struct MeasurementLayer: View { + @Environment(MeasurementManager.self) private var measurementManager + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @ScaledMetric private var measurementTextSize: CGFloat = 60 + + + var body: some View { + VStack(spacing: 15) { + Text(measurementManager.newMeasurement?.quantity.description ?? "") + .font(.system(size: measurementTextSize, weight: .bold, design: .rounded)) + .multilineTextAlignment(.center) + if dynamicTypeSize < .accessibility4 { + Text("Measurement Recorded") + .font(.title3) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + } +} + + +#Preview { + struct PreviewWrapperMeasurementLayer: View { + @Environment(MeasurementManager.self) private var measurementManager + + + var body: some View { + MeasurementLayer() + .onAppear { + measurementManager.loadMockMeasurement() + } + } + } + + return PreviewWrapperMeasurementLayer() + .previewWith(standard: ENGAGEHFStandard()) { + MeasurementManager() + } +} diff --git a/ENGAGEHF/Bluetooth/Views/MeasurementRecordedView.swift b/ENGAGEHF/Bluetooth/Views/MeasurementRecordedView.swift new file mode 100644 index 00000000..1d6f95c0 --- /dev/null +++ b/ENGAGEHF/Bluetooth/Views/MeasurementRecordedView.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: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import BluetoothServices +@_spi(TestingSupport) import SpeziBluetooth +import SpeziViews +import SwiftUI + + +struct MeasurementRecordedView: View { + private var dynamicDetente: 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) + } + } + + + @Environment(\.dynamicTypeSize) private var dynamicTypesize + @State var viewState = ViewState.idle + + + var body: some View { + VStack { + CloseButtonLayer(viewState: $viewState) + Spacer() + MeasurementLayer() + Spacer() + ConfirmMeasurementButton(viewState: $viewState) + } + .presentationDetents([dynamicDetente]) + .interactiveDismissDisabled(viewState != .idle) + } +} + + +#Preview { + struct MeasurementRecordedViewPreviewWrapper: View { + @Environment(MeasurementManager.self) private var measurementManager + @State private var viewState: ViewState = .idle + + + var body: some View { + @Bindable var measurementManager = measurementManager + + Button("Mock Measurement") { + measurementManager.loadMockMeasurement() + } + .sheet(isPresented: $measurementManager.showSheet) { + MeasurementRecordedView() + } + } + } + + return MeasurementRecordedViewPreviewWrapper() + .previewWith(standard: ENGAGEHFStandard()) { + MeasurementManager() + } +} diff --git a/ENGAGEHF/Bluetooth/Views/ViewElements.swift b/ENGAGEHF/Bluetooth/Views/ViewElements.swift new file mode 100644 index 00000000..7514b136 --- /dev/null +++ b/ENGAGEHF/Bluetooth/Views/ViewElements.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: 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() + }) { + Text(NSLocalizedString("Close", comment: "For closing sheets.")) + .foregroundStyle(Color.accentColor) + } + .buttonStyle(PlainButtonStyle()) + .disabled(viewState != .idle) + + Spacer() + } + .padding() + } + + + init(viewState: Binding) { + self._viewState = viewState + } +} diff --git a/ENGAGEHF/ENGAGEHF.swift b/ENGAGEHF/ENGAGEHF.swift index 8ca20010..0a342925 100644 --- a/ENGAGEHF/ENGAGEHF.swift +++ b/ENGAGEHF/ENGAGEHF.swift @@ -15,7 +15,7 @@ import SwiftUI struct ENGAGEHF: App { @UIApplicationDelegateAdaptor(ENGAGEHFDelegate.self) var appDelegate @AppStorage(StorageKeys.onboardingFlowComplete) var completedOnboardingFlow = false - + var body: some Scene { WindowGroup { diff --git a/ENGAGEHF/ENGAGEHFDelegate.swift b/ENGAGEHF/ENGAGEHFDelegate.swift index 419332e7..75a510ad 100644 --- a/ENGAGEHF/ENGAGEHFDelegate.swift +++ b/ENGAGEHF/ENGAGEHFDelegate.swift @@ -8,6 +8,7 @@ import Spezi import SpeziAccount +import SpeziBluetooth import SpeziFirebaseAccount import SpeziFirebaseStorage import SpeziFirestore @@ -49,7 +50,12 @@ class ENGAGEHFDelegate: SpeziAppDelegate { healthKit } + Bluetooth { + Discover(WeightScaleDevice.self, by: .advertisedService(WeightScaleService.self)) + } + OnboardingDataSource() + MeasurementManager() } } diff --git a/ENGAGEHF/ENGAGEHFStandard.swift b/ENGAGEHF/ENGAGEHFStandard.swift index ef61daa0..91a70127 100644 --- a/ENGAGEHF/ENGAGEHFStandard.swift +++ b/ENGAGEHF/ENGAGEHFStandard.swift @@ -97,7 +97,7 @@ actor ENGAGEHFStandard: Standard, EnvironmentAccessible, HealthKitConstraint, On private func healthKitDocument(id uuid: UUID) async throws -> DocumentReference { try await userDocumentReference - .collection("HealthKit") // Add all HealthKit sources in a /HealthKit collection. + .collection("HealthData") // Add all HealthKit sources in a /HealthData collection. .document(uuid.uuidString) // Set the document identifier to the UUID of the document. } diff --git a/ENGAGEHF/Home.swift b/ENGAGEHF/Home.swift index 9fad0da8..55af8f44 100644 --- a/ENGAGEHF/Home.swift +++ b/ENGAGEHF/Home.swift @@ -7,6 +7,8 @@ // import SpeziAccount +import SpeziBluetooth +import SpeziViews import SwiftUI @@ -18,13 +20,23 @@ struct HomeView: View { static var accountEnabled: Bool { !FeatureFlags.disableFirebase && !FeatureFlags.skipOnboarding } - - + + private var bluetoothEnabled: Bool { + !ProcessInfo.processInfo.isPreviewSimulator + } + + + @Environment(MeasurementManager.self) private var measurementManager + @Environment(WeightScaleDevice.self) private var weightScale: WeightScaleDevice? + @Environment(Bluetooth.self) private var bluetooth + @AppStorage(StorageKeys.homeTabSelection) private var selectedTab = Tabs.home @State private var presentingAccount = false - + var body: some View { + @Bindable var measurementManager = measurementManager + TabView(selection: $selectedTab) { Dashboard(presentingAccount: $presentingAccount) .tag(Tabs.home) @@ -32,6 +44,7 @@ struct HomeView: View { Label("Home", systemImage: "house") } } + .autoConnect(enabled: bluetoothEnabled, with: bluetooth) .sheet(isPresented: $presentingAccount) { AccountSheet() } @@ -39,6 +52,15 @@ struct HomeView: View { AccountSheet() } .verifyRequiredAccountDetails(Self.accountEnabled) + .sheet( + isPresented: $measurementManager.showSheet, + onDismiss: { + measurementManager.clear() + }, + content: { + MeasurementRecordedView() + } + ) } } @@ -50,5 +72,9 @@ struct HomeView: View { AccountConfiguration { MockUserIdPasswordAccountService() } + MeasurementManager() + Bluetooth { + Discover(WeightScaleDevice.self, by: .advertisedService(WeightScaleService.self)) + } } } diff --git a/ENGAGEHF/Resources/Localizable.xcstrings b/ENGAGEHF/Resources/Localizable.xcstrings index 621acf05..39dfa62f 100644 --- a/ENGAGEHF/Resources/Localizable.xcstrings +++ b/ENGAGEHF/Resources/Localizable.xcstrings @@ -57,6 +57,9 @@ }, "An invitation code is at least 8 characters long." : { + }, + "Close" : { + "comment" : "For closing sheets." }, "CLOSE" : { "comment" : "MARK: General", @@ -88,6 +91,9 @@ } } } + }, + "Discard" : { + }, "HEALTHKIT_PERMISSIONS_BUTTON" : { "localizations" : { @@ -292,6 +298,12 @@ } } } + }, + "Measurement Recorded" : { + + }, + "Mock Measurement" : { + }, "NOTIFICATION_PERMISSIONS_BUTTON" : { "localizations" : { @@ -339,6 +351,9 @@ }, "Redeem Invitation Code" : { + }, + "Save" : { + }, "The invitation code is invalid or has already been used." : { "comment" : "Invitation Code Invalid" diff --git a/ENGAGEHF/Supporting Files/Info.plist b/ENGAGEHF/Supporting Files/Info.plist index 5dc3321f..8541b3e3 100644 --- a/ENGAGEHF/Supporting Files/Info.plist +++ b/ENGAGEHF/Supporting Files/Info.plist @@ -2,6 +2,8 @@ + CFBundleAllowMixedLocalizations + ITSAppUsesNonExemptEncryption UIApplicationSceneManifest @@ -11,7 +13,5 @@ UISceneConfigurations - CFBundleAllowMixedLocalizations - diff --git a/ENGAGEHFUITests/OnboardingUITests.swift b/ENGAGEHFUITests/OnboardingUITests.swift index c9e8b1c0..ad68c80b 100644 --- a/ENGAGEHFUITests/OnboardingUITests.swift +++ b/ENGAGEHFUITests/OnboardingUITests.swift @@ -173,6 +173,7 @@ extension XCUIApplication { textFields["Enter your last name ..."].typeText("Stanford") XCTAssertTrue(scrollViews["Signature Field"].waitForExistence(timeout: 2)) + scrollViews["Signature Field"].tap() scrollViews["Signature Field"].swipeRight() XCTAssert(buttons["I Consent"].waitForExistence(timeout: 2) diff --git a/firebase.json b/firebase.json index 00b4e6d8..11f4ba43 100644 --- a/firebase.json +++ b/firebase.json @@ -16,20 +16,24 @@ ], "emulators": { "auth": { - "port": 9099 + "port": 9099, + "host": "0.0.0.0" }, "firestore": { - "port": 8080 + "port": 8080, + "host": "0.0.0.0" }, "functions": { - "port": 5001 + "port": 5001, + "host": "0.0.0.0" }, "ui": { "enabled": true, - "port": 4000 + "host": "0.0.0.0" }, "storage": { - "port": 9199 + "port": 9199, + "host": "0.0.0.0" }, "singleProjectMode": true }