diff --git a/.github/workflows/beta-deployment.yml b/.github/workflows/beta-deployment.yml index 12c51e9..3594787 100644 --- a/.github/workflows/beta-deployment.yml +++ b/.github/workflows/beta-deployment.yml @@ -41,6 +41,5 @@ jobs: contents: read with: arguments: '--debug' - customcommand: 'npm install --prefix functions' secrets: GOOGLE_APPLICATION_CREDENTIALS_BASE64: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS_BASE64 }} diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 7f17da6..fe2d422 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -55,7 +55,7 @@ jobs: runsonlabels: '["macOS", "self-hosted"]' setupSimulators: true setupfirebaseemulator: true - customcommand: "npm install --prefix ./functions && firebase emulators:exec 'fastlane test'" + customcommand: "npm install --prefix ./functions && firebase emulators:exec --import=./firebase 'fastlane test'" uploadcoveragereport: name: Upload Coverage Report needs: buildandtest diff --git a/.reuse/dep5 b/.reuse/dep5 new file mode 100644 index 0000000..35e489a --- /dev/null +++ b/.reuse/dep5 @@ -0,0 +1,6 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + +Files: firebase/* +Copyright: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +License: MIT +Comment: All firebase emulator testing data is part of the Stanford Spezi Study Application open source project. \ No newline at end of file diff --git a/README.md b/README.md index 3ae5e97..101bbc9 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,31 @@ SPDX-License-Identifier: MIT --> -# StudyApplication +# Spezi Study Application -This repository contains the StudyApplication. -The StudyApplication is using the [Spezi](https://github.com/StanfordSpezi/Spezi) ecosystem and builds on top of the [Stanford Spezi Template Application](https://github.com/StanfordSpezi/SpeziTemplateApplication). +This repository contains the Spezi Study Application. + +> [!NOTE] +> Do you want to download the Spezi Study Application application? You can download it to your iOS device using [TestFlight](https://testflight.apple.com/join/wlseiMRR)! + + +## Build & Run The Spezi Study Application + +The Spezi Study Application uses the [Spezi](https://github.com/StanfordSpezi/Spezi) ecosystem and is based of the [Stanford Spezi Template Application](https://github.com/StanfordSpezi/SpeziTemplateApplication). + +Please refer to the [Build And Run a Spezi Template Application-based Application](https://spezi.health/SpeziTemplateApplication/documentation/templateapplication/setup) instructions and other documentation for the [Spezi Template Application](https://github.com/StanfordSpezi/SpeziTemplateApplication) and [Stanford Spezi](https://github.com/StanfordSpezi/Spezi) to learn how to build and run the application using Xcode. + + +## Firebase Emulator-based Development & Testing + +Similar to the [Stanford Spezi Template Application](https://github.com/StanfordSpezi/SpeziTemplateApplication), the Study Application uses Firebase to handle user authentication, data storage, and automation using cloud functions. + +The [Firebase Local Emulator Suite](https://firebase.google.com/docs/emulator-suite) allows you to replicate a full cloud stack within your development environment. +To load in the development and testing data to test and build the application locally, you need to start the emulator using +```bash +$ firebase emulators:start --import=./firebase +``` +at the root of the Spezi Study Application repository. > [!NOTE] > Do you want to learn more about the Stanford Spezi Template Application and how to use, extend, and modify this application? Check out the [Stanford Spezi Template Application documentation](https://stanfordspezi.github.io/SpeziTemplateApplication) @@ -21,8 +42,6 @@ The StudyApplication is using the [Spezi](https://github.com/StanfordSpezi/Spezi Contributions to this project are welcome. Please make sure to read the [contribution guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md) and the [contributor covenant code of conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) first. -This project is based on [ContinuousDelivery Example by Paul Schmiedmayer](https://github.com/PSchmiedmayer/ContinousDelivery) and the [Stanford Biodesign Digital Health Template Application](https://github.com/StanfordBDHG/TemplateApplication) provided using the MIT license. - ## License diff --git a/StudyApplication.xcodeproj/project.pbxproj b/StudyApplication.xcodeproj/project.pbxproj index d04de1d..c33004c 100644 --- a/StudyApplication.xcodeproj/project.pbxproj +++ b/StudyApplication.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 27FA29902A388E9B009CAC45 /* ModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FA298F2A388E9B009CAC45 /* ModalView.swift */; }; + 2F2168AF2BA4F3A7004603AE /* Home.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F2168AE2BA4F3A7004603AE /* Home.swift */; }; + 2F2168B32BA4FBF3004603AE /* MockStudy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F2168B22BA4FBF3004603AE /* MockStudy.swift */; }; 2F2A56AF2B9DA6DF00B3534C /* SpeziFirebaseConfiguration in Frameworks */ = {isa = PBXBuildFile; productRef = 2F2A56AE2B9DA6DF00B3534C /* SpeziFirebaseConfiguration */; }; 2F2A56B12B9DA6DF00B3534C /* SpeziFirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 2F2A56B02B9DA6DF00B3534C /* SpeziFirebaseStorage */; }; 2F2A56B32B9DA6DF00B3534C /* SpeziFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 2F2A56B22B9DA6DF00B3534C /* SpeziFirestore */; }; @@ -19,12 +21,22 @@ 2F49B7762980407C00BCB272 /* Spezi in Frameworks */ = {isa = PBXBuildFile; productRef = 2F49B7752980407B00BCB272 /* Spezi */; }; 2F6025CB29BBE70F0045459E /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2F6025CA29BBE70F0045459E /* GoogleService-Info.plist */; }; 2F65B44E2A3B8B0600A36932 /* NotificationPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F65B44D2A3B8B0600A36932 /* NotificationPermissions.swift */; }; + 2F6B41652BB0B6DF00A6ADA2 /* StudyImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F6B41642BB0B6DF00A6ADA2 /* StudyImage.swift */; }; + 2F6B41682BB0B6E300A6ADA2 /* StudyHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F6B41672BB0B6E300A6ADA2 /* StudyHeader.swift */; }; + 2F6B416A2BB0FE0500A6ADA2 /* Gauge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F6B41692BB0FE0500A6ADA2 /* Gauge.swift */; }; 2FA0BFED2ACC977500E0EF83 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */; }; 2FB099AF2A875DF100B20952 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB099AE2A875DF100B20952 /* FirebaseAuth */; }; 2FB099B12A875DF100B20952 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB099B02A875DF100B20952 /* FirebaseFirestore */; }; 2FB099B32A875DF100B20952 /* FirebaseFirestoreSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB099B22A875DF100B20952 /* FirebaseFirestoreSwift */; }; 2FB099B62A875E2B00B20952 /* HealthKitOnFHIR in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB099B52A875E2B00B20952 /* HealthKitOnFHIR */; }; 2FBD738C2A3BD150004228E7 /* SpeziScheduler in Frameworks */ = {isa = PBXBuildFile; productRef = 2FBD738B2A3BD150004228E7 /* SpeziScheduler */; }; + 2FBF11702BAE918B0084DC05 /* DailyStepCountGoalModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FBF116F2BAE918B0084DC05 /* DailyStepCountGoalModule.swift */; }; + 2FBF11752BAF69BA0084DC05 /* Observation+Continous.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FBF11742BAF69BA0084DC05 /* Observation+Continous.swift */; }; + 2FBF11792BAF6DFB0084DC05 /* Engagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FBF11782BAF6DFB0084DC05 /* Engagement.swift */; }; + 2FBF11862BB098660084DC05 /* DailyStepCountGoal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FBF11852BB098660084DC05 /* DailyStepCountGoal.swift */; }; + 2FBF118B2BB0999D0084DC05 /* StudyEnrollment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FBF118A2BB0999D0084DC05 /* StudyEnrollment.swift */; }; + 2FBF118E2BB0A5110084DC05 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 2FBF118D2BB0A5110084DC05 /* OrderedCollections */; }; + 2FBF11912BB0A9970084DC05 /* StudyApplicationListCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FBF11902BB0A9970084DC05 /* StudyApplicationListCard.swift */; }; 2FC3439029EE6346002D773C /* SocialSupportQuestionnaire.json in Resources */ = {isa = PBXBuildFile; fileRef = 2FE5DC5529EDD811004B9AB4 /* SocialSupportQuestionnaire.json */; }; 2FC3439129EE6349002D773C /* AppIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = 2FE5DC2A29EDD78D004B9AB4 /* AppIcon.png */; }; 2FC3439229EE634B002D773C /* ConsentDocument.md in Resources */ = {isa = PBXBuildFile; fileRef = 2FE5DC2C29EDD78E004B9AB4 /* ConsentDocument.md */; }; @@ -33,7 +45,7 @@ 2FC5FB8C2B34F1CA00F13E59 /* Organization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC5FB8B2B34F1CA00F13E59 /* Organization.swift */; }; 2FC5FB922B34F24E00F13E59 /* VascTracStudy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC5FB912B34F24E00F13E59 /* VascTracStudy.swift */; }; 2FC5FB942B34F55B00F13E59 /* StudyModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC5FB932B34F55B00F13E59 /* StudyModule.swift */; }; - 2FC5FB9A2B350B4900F13E59 /* StudyState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC5FB992B350B4900F13E59 /* StudyState.swift */; }; + 2FC5FB9A2B350B4900F13E59 /* State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC5FB992B350B4900F13E59 /* State.swift */; }; 2FC5FBA32B3511E400F13E59 /* StudyOnboardingMechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC5FBA22B3511E300F13E59 /* StudyOnboardingMechanism.swift */; }; 2FC5FBA52B3511EA00F13E59 /* InviationCodeStudyOnboardingMechanismError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC5FBA42B3511EA00F13E59 /* InviationCodeStudyOnboardingMechanismError.swift */; }; 2FC5FBA72B3511F400F13E59 /* InviationCodeStudyOnboardingMechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC5FBA62B3511F400F13E59 /* InviationCodeStudyOnboardingMechanism.swift */; }; @@ -41,7 +53,7 @@ 2FC5FBAF2B3513DD00F13E59 /* StudyError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC5FBAE2B3513DD00F13E59 /* StudyError.swift */; }; 2FC5FBB22B35153000F13E59 /* StudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC5FBB12B35153000F13E59 /* StudyView.swift */; }; 2FC5FBB52B351FF800F13E59 /* StudyOnboardingFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC5FBB42B351FF800F13E59 /* StudyOnboardingFlow.swift */; }; - 2FC5FBBC2B35758700F13E59 /* StudyHealthKitAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC5FBBB2B35758700F13E59 /* StudyHealthKitAccess.swift */; }; + 2FC5FBBC2B35758700F13E59 /* HealthKitAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC5FBBB2B35758700F13E59 /* HealthKitAccess.swift */; }; 2FC5FBC52B357BD700F13E59 /* AHA LE8.json in Resources */ = {isa = PBXBuildFile; fileRef = 2FC5FBBF2B357BD700F13E59 /* AHA LE8.json */; }; 2FC5FBC62B357BD700F13E59 /* Exercise Benefits Barriers.json in Resources */ = {isa = PBXBuildFile; fileRef = 2FC5FBC02B357BD700F13E59 /* Exercise Benefits Barriers.json */; }; 2FC5FBC72B357BD700F13E59 /* PAD Walking Impairment.json in Resources */ = {isa = PBXBuildFile; fileRef = 2FC5FBC12B357BD700F13E59 /* PAD Walking Impairment.json */; }; @@ -49,7 +61,7 @@ 2FC5FBC92B357BD700F13E59 /* PHQ-9 Depression.json in Resources */ = {isa = PBXBuildFile; fileRef = 2FC5FBC32B357BD700F13E59 /* PHQ-9 Depression.json */; }; 2FC5FBCA2B357BD700F13E59 /* Berlin.json in Resources */ = {isa = PBXBuildFile; fileRef = 2FC5FBC42B357BD700F13E59 /* Berlin.json */; }; 2FC5FBCD2B357F3F00F13E59 /* StudiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC5FBCC2B357F3F00F13E59 /* StudiesView.swift */; }; - 2FC975A82978F11A00BA99FE /* Home.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC975A72978F11A00BA99FE /* Home.swift */; }; + 2FC975A82978F11A00BA99FE /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC975A72978F11A00BA99FE /* MainView.swift */; }; 2FD08B5F2B9DBE9200AC7523 /* FirebaseAccountConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FD08B5E2B9DBE9200AC7523 /* FirebaseAccountConfiguration.swift */; }; 2FD08B622B9DC17200AC7523 /* FirebaseFunctions in Frameworks */ = {isa = PBXBuildFile; productRef = 2FD08B612B9DC17200AC7523 /* FirebaseFunctions */; }; 2FD08B652B9DC2E500AC7523 /* FirebaseFunctionsConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FD08B642B9DC2E500AC7523 /* FirebaseFunctionsConfiguration.swift */; }; @@ -62,7 +74,6 @@ 2FE5DC4029EDD7EE004B9AB4 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DC3E29EDD7ED004B9AB4 /* FeatureFlags.swift */; }; 2FE5DC4129EDD7EE004B9AB4 /* StorageKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DC3F29EDD7EE004B9AB4 /* StorageKeys.swift */; }; 2FE5DC4529EDD7F2004B9AB4 /* Binding+Negate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DC4229EDD7F2004B9AB4 /* Binding+Negate.swift */; }; - 2FE5DC4629EDD7F2004B9AB4 /* Bundle+Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DC4329EDD7F2004B9AB4 /* Bundle+Image.swift */; }; 2FE5DC4729EDD7F2004B9AB4 /* CodableArray+RawRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DC4429EDD7F2004B9AB4 /* CodableArray+RawRepresentable.swift */; }; 2FE5DC4E29EDD7FA004B9AB4 /* ScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DC4829EDD7FA004B9AB4 /* ScheduleView.swift */; }; 2FE5DC4F29EDD7FA004B9AB4 /* EventContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DC4929EDD7FA004B9AB4 /* EventContext.swift */; }; @@ -110,20 +121,31 @@ /* Begin PBXFileReference section */ 27FA298F2A388E9B009CAC45 /* ModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalView.swift; sourceTree = ""; }; + 2F2168AE2BA4F3A7004603AE /* Home.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Home.swift; sourceTree = ""; }; + 2F2168B22BA4FBF3004603AE /* MockStudy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStudy.swift; sourceTree = ""; }; 2F2A56B42B9DA74100B3534C /* StudyApplicationDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StudyApplicationDelegate.swift; sourceTree = ""; }; 2F2A56B52B9DA74100B3534C /* StudyApplicationTestingSetup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StudyApplicationTestingSetup.swift; sourceTree = ""; }; 2F2A56B92B9DAA5500B3534C /* String+LocalizedError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+LocalizedError.swift"; sourceTree = ""; }; 2F3AF7352B9D9B8400340B4F /* Peripheral Artery Questionnaire (PAQ).json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "Peripheral Artery Questionnaire (PAQ).json"; sourceTree = ""; }; 2F6025CA29BBE70F0045459E /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 2F65B44D2A3B8B0600A36932 /* NotificationPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissions.swift; sourceTree = ""; }; + 2F6B41642BB0B6DF00A6ADA2 /* StudyImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyImage.swift; sourceTree = ""; }; + 2F6B41672BB0B6E300A6ADA2 /* StudyHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyHeader.swift; sourceTree = ""; }; + 2F6B41692BB0FE0500A6ADA2 /* Gauge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Gauge.swift; sourceTree = ""; }; 2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 2FAEC07F297F583900C11C42 /* StudyApplication.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = StudyApplication.entitlements; sourceTree = ""; }; + 2FBF116F2BAE918B0084DC05 /* DailyStepCountGoalModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyStepCountGoalModule.swift; sourceTree = ""; }; + 2FBF11742BAF69BA0084DC05 /* Observation+Continous.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Observation+Continous.swift"; sourceTree = ""; }; + 2FBF11782BAF6DFB0084DC05 /* Engagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Engagement.swift; sourceTree = ""; }; + 2FBF11852BB098660084DC05 /* DailyStepCountGoal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyStepCountGoal.swift; sourceTree = ""; }; + 2FBF118A2BB0999D0084DC05 /* StudyEnrollment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyEnrollment.swift; sourceTree = ""; }; + 2FBF11902BB0A9970084DC05 /* StudyApplicationListCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyApplicationListCard.swift; sourceTree = ""; }; 2FC5FB842B34EA6700F13E59 /* StudyApplicationUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyApplicationUITests.swift; sourceTree = ""; }; 2FC5FB882B34F09500F13E59 /* Study.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Study.swift; sourceTree = ""; }; 2FC5FB8B2B34F1CA00F13E59 /* Organization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Organization.swift; sourceTree = ""; }; 2FC5FB912B34F24E00F13E59 /* VascTracStudy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VascTracStudy.swift; sourceTree = ""; }; 2FC5FB932B34F55B00F13E59 /* StudyModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyModule.swift; sourceTree = ""; }; - 2FC5FB992B350B4900F13E59 /* StudyState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyState.swift; sourceTree = ""; }; + 2FC5FB992B350B4900F13E59 /* State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = State.swift; sourceTree = ""; }; 2FC5FBA22B3511E300F13E59 /* StudyOnboardingMechanism.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyOnboardingMechanism.swift; sourceTree = ""; }; 2FC5FBA42B3511EA00F13E59 /* InviationCodeStudyOnboardingMechanismError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviationCodeStudyOnboardingMechanismError.swift; sourceTree = ""; }; 2FC5FBA62B3511F400F13E59 /* InviationCodeStudyOnboardingMechanism.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviationCodeStudyOnboardingMechanism.swift; sourceTree = ""; }; @@ -131,7 +153,7 @@ 2FC5FBAE2B3513DD00F13E59 /* StudyError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyError.swift; sourceTree = ""; }; 2FC5FBB12B35153000F13E59 /* StudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyView.swift; sourceTree = ""; }; 2FC5FBB42B351FF800F13E59 /* StudyOnboardingFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyOnboardingFlow.swift; sourceTree = ""; }; - 2FC5FBBB2B35758700F13E59 /* StudyHealthKitAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyHealthKitAccess.swift; sourceTree = ""; }; + 2FC5FBBB2B35758700F13E59 /* HealthKitAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitAccess.swift; sourceTree = ""; }; 2FC5FBBF2B357BD700F13E59 /* AHA LE8.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "AHA LE8.json"; sourceTree = ""; }; 2FC5FBC02B357BD700F13E59 /* Exercise Benefits Barriers.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "Exercise Benefits Barriers.json"; sourceTree = ""; }; 2FC5FBC12B357BD700F13E59 /* PAD Walking Impairment.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "PAD Walking Impairment.json"; sourceTree = ""; }; @@ -140,7 +162,7 @@ 2FC5FBC42B357BD700F13E59 /* Berlin.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = Berlin.json; sourceTree = ""; }; 2FC5FBCC2B357F3F00F13E59 /* StudiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudiesView.swift; sourceTree = ""; }; 2FC94CD4298B0A1D009C8209 /* StudyApplication.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = StudyApplication.xctestplan; sourceTree = ""; }; - 2FC975A72978F11A00BA99FE /* Home.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Home.swift; sourceTree = ""; }; + 2FC975A72978F11A00BA99FE /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; 2FD08B5E2B9DBE9200AC7523 /* FirebaseAccountConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseAccountConfiguration.swift; sourceTree = ""; }; 2FD08B642B9DC2E500AC7523 /* FirebaseFunctionsConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseFunctionsConfiguration.swift; sourceTree = ""; }; 2FE5DC2A29EDD78D004B9AB4 /* AppIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = AppIcon.png; sourceTree = ""; }; @@ -153,7 +175,6 @@ 2FE5DC3E29EDD7ED004B9AB4 /* FeatureFlags.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; 2FE5DC3F29EDD7EE004B9AB4 /* StorageKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StorageKeys.swift; sourceTree = ""; }; 2FE5DC4229EDD7F2004B9AB4 /* Binding+Negate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Binding+Negate.swift"; sourceTree = ""; }; - 2FE5DC4329EDD7F2004B9AB4 /* Bundle+Image.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Bundle+Image.swift"; sourceTree = ""; }; 2FE5DC4429EDD7F2004B9AB4 /* CodableArray+RawRepresentable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CodableArray+RawRepresentable.swift"; sourceTree = ""; }; 2FE5DC4829EDD7FA004B9AB4 /* ScheduleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScheduleView.swift; sourceTree = ""; }; 2FE5DC4929EDD7FA004B9AB4 /* EventContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventContext.swift; sourceTree = ""; }; @@ -181,6 +202,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 2FBF118E2BB0A5110084DC05 /* OrderedCollections in Frameworks */, 9733CFC62A8066DE001B7ABC /* SpeziOnboarding in Frameworks */, 2FB099AF2A875DF100B20952 /* FirebaseAuth in Frameworks */, 2FE5DC6729EDD894004B9AB4 /* SpeziContact in Frameworks */, @@ -224,6 +246,72 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 2F2168AD2BA4F39D004603AE /* Home */ = { + isa = PBXGroup; + children = ( + 2F2168AE2BA4F3A7004603AE /* Home.swift */, + ); + path = Home; + sourceTree = ""; + }; + 2F2168B02BA4FBDF004603AE /* MockStudy */ = { + isa = PBXGroup; + children = ( + 2F2168B22BA4FBF3004603AE /* MockStudy.swift */, + ); + path = MockStudy; + sourceTree = ""; + }; + 2FBF117E2BAF70E90084DC05 /* Views */ = { + isa = PBXGroup; + children = ( + 2FC5FBB12B35153000F13E59 /* StudyView.swift */, + 2F6B41642BB0B6DF00A6ADA2 /* StudyImage.swift */, + 2F6B41672BB0B6E300A6ADA2 /* StudyHeader.swift */, + 2FC5FBCC2B357F3F00F13E59 /* StudiesView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 2FBF11802BAF713C0084DC05 /* Modules */ = { + isa = PBXGroup; + children = ( + 2FC5FB882B34F09500F13E59 /* Study.swift */, + 2FC5FB992B350B4900F13E59 /* State.swift */, + 2FC5FBBB2B35758700F13E59 /* HealthKitAccess.swift */, + 2FC5FB8B2B34F1CA00F13E59 /* Organization.swift */, + 2FBF11782BAF6DFB0084DC05 /* Engagement.swift */, + ); + path = Modules; + sourceTree = ""; + }; + 2FBF11822BB0981D0084DC05 /* Engagements */ = { + isa = PBXGroup; + children = ( + 2FBF11882BB0998E0084DC05 /* StudyEnrollment */, + 2FBF11842BB098310084DC05 /* DailyStepCountGoal */, + ); + path = Engagements; + sourceTree = ""; + }; + 2FBF11842BB098310084DC05 /* DailyStepCountGoal */ = { + isa = PBXGroup; + children = ( + 2FBF116F2BAE918B0084DC05 /* DailyStepCountGoalModule.swift */, + 2FBF11852BB098660084DC05 /* DailyStepCountGoal.swift */, + 2F6B41692BB0FE0500A6ADA2 /* Gauge.swift */, + ); + path = DailyStepCountGoal; + sourceTree = ""; + }; + 2FBF11882BB0998E0084DC05 /* StudyEnrollment */ = { + isa = PBXGroup; + children = ( + 2FBF118A2BB0999D0084DC05 /* StudyEnrollment.swift */, + ); + path = StudyEnrollment; + sourceTree = ""; + }; 2FC5FB802B34E69400F13E59 /* StudyOnboarding */ = { isa = PBXGroup; children = ( @@ -239,14 +327,10 @@ 2FC5FB872B34F08500F13E59 /* Study */ = { isa = PBXGroup; children = ( - 2FC5FB882B34F09500F13E59 /* Study.swift */, - 2FC5FB992B350B4900F13E59 /* StudyState.swift */, 2FC5FB932B34F55B00F13E59 /* StudyModule.swift */, - 2FC5FBBB2B35758700F13E59 /* StudyHealthKitAccess.swift */, 2FC5FBAE2B3513DD00F13E59 /* StudyError.swift */, - 2FC5FB8B2B34F1CA00F13E59 /* Organization.swift */, - 2FC5FBB12B35153000F13E59 /* StudyView.swift */, - 2FC5FBCC2B357F3F00F13E59 /* StudiesView.swift */, + 2FBF117E2BAF70E90084DC05 /* Views */, + 2FBF11802BAF713C0084DC05 /* Modules */, ); path = Study; sourceTree = ""; @@ -254,6 +338,7 @@ 2FC5FB8E2B34F20200F13E59 /* Studies */ = { isa = PBXGroup; children = ( + 2F2168B02BA4FBDF004603AE /* MockStudy */, 2FC5FB902B34F20B00F13E59 /* VascTrac */, ); path = Studies; @@ -351,6 +436,7 @@ 2FE5DC3C29EDD7DA004B9AB4 /* SharedContext */ = { isa = PBXGroup; children = ( + 2FBF11902BB0A9970084DC05 /* StudyApplicationListCard.swift */, 2FE5DC3E29EDD7ED004B9AB4 /* FeatureFlags.swift */, 2FE5DC3F29EDD7EE004B9AB4 /* StorageKeys.swift */, ); @@ -361,9 +447,9 @@ isa = PBXGroup; children = ( 2FE5DC4229EDD7F2004B9AB4 /* Binding+Negate.swift */, - 2FE5DC4329EDD7F2004B9AB4 /* Bundle+Image.swift */, 2FE5DC4429EDD7F2004B9AB4 /* CodableArray+RawRepresentable.swift */, 2F2A56B92B9DAA5500B3534C /* String+LocalizedError.swift */, + 2FBF11742BAF69BA0084DC05 /* Observation+Continous.swift */, ); path = Helper; sourceTree = ""; @@ -408,8 +494,10 @@ 2F2A56B42B9DA74100B3534C /* StudyApplicationDelegate.swift */, 2FF53D8C2A8729D600042B76 /* StudyApplicationStandard.swift */, 2F2A56B52B9DA74100B3534C /* StudyApplicationTestingSetup.swift */, - 2FC975A72978F11A00BA99FE /* Home.swift */, + 2FC975A72978F11A00BA99FE /* MainView.swift */, + 2F2168AD2BA4F39D004603AE /* Home */, 2FC5FB872B34F08500F13E59 /* Study */, + 2FBF11822BB0981D0084DC05 /* Engagements */, 2FC5FB802B34E69400F13E59 /* StudyOnboarding */, 2FC5FB8E2B34F20200F13E59 /* Studies */, 2FE5DC2829EDD398004B9AB4 /* Onboarding */, @@ -487,6 +575,7 @@ 2F2A56B22B9DA6DF00B3534C /* SpeziFirestore */, 2FD08B612B9DC17200AC7523 /* FirebaseFunctions */, 2FD08B692B9DC4D100AC7523 /* SpeziValidation */, + 2FBF118D2BB0A5110084DC05 /* OrderedCollections */, ); productName = StudyApplication; productReference = 653A254D283387FE005D4D48 /* StudyApplication.app */; @@ -581,6 +670,7 @@ 2FB099B42A875E2B00B20952 /* XCRemoteSwiftPackageReference "HealthKitOnFHIR" */, 5661551B2AB8384200209B80 /* XCRemoteSwiftPackageReference "swift-package-list" */, 2F2A56A92B9DA6DF00B3534C /* XCRemoteSwiftPackageReference "SpeziFirebase" */, + 2FBF118C2BB0A5110084DC05 /* XCRemoteSwiftPackageReference "swift-collections" */, ); productRefGroup = 653A254E283387FE005D4D48 /* Products */; projectDirPath = ""; @@ -659,45 +749,55 @@ 2FE5DC4129EDD7EE004B9AB4 /* StorageKeys.swift in Sources */, 2FE5DC3A29EDD7CA004B9AB4 /* Welcome.swift in Sources */, 2FE5DC3829EDD7CA004B9AB4 /* InterestingModules.swift in Sources */, - 2FC5FB9A2B350B4900F13E59 /* StudyState.swift in Sources */, + 2FC5FB9A2B350B4900F13E59 /* State.swift in Sources */, 2FE5DC3529EDD7CA004B9AB4 /* Consent.swift in Sources */, + 2FBF11862BB098660084DC05 /* DailyStepCountGoal.swift in Sources */, 2FC5FBA32B3511E400F13E59 /* StudyOnboardingMechanism.swift in Sources */, + 2F6B41652BB0B6DF00A6ADA2 /* StudyImage.swift in Sources */, 2FE5DC4529EDD7F2004B9AB4 /* Binding+Negate.swift in Sources */, - 2FC975A82978F11A00BA99FE /* Home.swift in Sources */, + 2FC975A82978F11A00BA99FE /* MainView.swift in Sources */, 2FE5DC4E29EDD7FA004B9AB4 /* ScheduleView.swift in Sources */, 2FD08B5F2B9DBE9200AC7523 /* FirebaseAccountConfiguration.swift in Sources */, 2FE5DC3729EDD7CA004B9AB4 /* OnboardingFlow.swift in Sources */, 2FF53D8D2A8729D600042B76 /* StudyApplicationStandard.swift in Sources */, + 2FBF11792BAF6DFB0084DC05 /* Engagement.swift in Sources */, + 2FBF118B2BB0999D0084DC05 /* StudyEnrollment.swift in Sources */, 2F2A56B62B9DA74100B3534C /* StudyApplicationDelegate.swift in Sources */, 2FE5DC4729EDD7F2004B9AB4 /* CodableArray+RawRepresentable.swift in Sources */, 2FE5DC4029EDD7EE004B9AB4 /* FeatureFlags.swift in Sources */, - 2FE5DC4629EDD7F2004B9AB4 /* Bundle+Image.swift in Sources */, + 2FBF11752BAF69BA0084DC05 /* Observation+Continous.swift in Sources */, 2F2A56BA2B9DAA5500B3534C /* String+LocalizedError.swift in Sources */, + 2F2168B32BA4FBF3004603AE /* MockStudy.swift in Sources */, 2FE5DC4F29EDD7FA004B9AB4 /* EventContext.swift in Sources */, 2FC5FBB22B35153000F13E59 /* StudyView.swift in Sources */, 2FE5DC5029EDD7FA004B9AB4 /* EventContextView.swift in Sources */, 2FC5FBCD2B357F3F00F13E59 /* StudiesView.swift in Sources */, 2FC5FBA52B3511EA00F13E59 /* InviationCodeStudyOnboardingMechanismError.swift in Sources */, + 2F2168AF2BA4F3A7004603AE /* Home.swift in Sources */, 2FC5FBA72B3511F400F13E59 /* InviationCodeStudyOnboardingMechanism.swift in Sources */, + 2F6B416A2BB0FE0500A6ADA2 /* Gauge.swift in Sources */, 2FC5FB942B34F55B00F13E59 /* StudyModule.swift in Sources */, 2FC5FB8C2B34F1CA00F13E59 /* Organization.swift in Sources */, 2FD08B652B9DC2E500AC7523 /* FirebaseFunctionsConfiguration.swift in Sources */, 2FE5DC5329EDD7FA004B9AB4 /* Bundle+Questionnaire.swift in Sources */, + 2FBF11912BB0A9970084DC05 /* StudyApplicationListCard.swift in Sources */, 2FC5FBA92B3511FA00F13E59 /* InvitationCodeView.swift in Sources */, 2FE5DC5129EDD7FA004B9AB4 /* StudyApplicationTaskContext.swift in Sources */, 56F6F2A02AB441930022FE5A /* ContributionsList.swift in Sources */, 566155292AB8447C00209B80 /* Package+LicenseType.swift in Sources */, + 2FBF11702BAE918B0084DC05 /* DailyStepCountGoalModule.swift in Sources */, 5680DD392AB8983D004E6D4A /* PackageCell.swift in Sources */, 2FE5DC5229EDD7FA004B9AB4 /* StudyApplicationScheduler.swift in Sources */, 653A2551283387FE005D4D48 /* StudyApplication.swift in Sources */, 2FC5FBAF2B3513DD00F13E59 /* StudyError.swift in Sources */, 2FE5DC3629EDD7CA004B9AB4 /* HealthKitPermissions.swift in Sources */, + 2F6B41682BB0B6E300A6ADA2 /* StudyHeader.swift in Sources */, 2FC5FBB52B351FF800F13E59 /* StudyOnboardingFlow.swift in Sources */, 2FC5FB892B34F09500F13E59 /* Study.swift in Sources */, 2F65B44E2A3B8B0600A36932 /* NotificationPermissions.swift in Sources */, 5661552E2AB854C000209B80 /* PackageHelper.swift in Sources */, 2FC5FB922B34F24E00F13E59 /* VascTracStudy.swift in Sources */, - 2FC5FBBC2B35758700F13E59 /* StudyHealthKitAccess.swift in Sources */, + 2FC5FBBC2B35758700F13E59 /* HealthKitAccess.swift in Sources */, 27FA29902A388E9B009CAC45 /* ModalView.swift in Sources */, 2F2A56B72B9DA74100B3534C /* StudyApplicationTestingSetup.swift in Sources */, ); @@ -1254,6 +1354,14 @@ minimumVersion = 0.2.6; }; }; + 2FBF118C2BB0A5110084DC05 /* XCRemoteSwiftPackageReference "swift-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-collections.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.0; + }; + }; 2FE5DC6229EDD883004B9AB4 /* XCRemoteSwiftPackageReference "SpeziAccount" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/StanfordSpezi/SpeziAccount.git"; @@ -1394,6 +1502,11 @@ isa = XCSwiftPackageProductDependency; productName = SpeziScheduler; }; + 2FBF118D2BB0A5110084DC05 /* OrderedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = 2FBF118C2BB0A5110084DC05 /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = OrderedCollections; + }; 2FD08B612B9DC17200AC7523 /* FirebaseFunctions */ = { isa = XCSwiftPackageProductDependency; package = 2FE5DC9029EDD9C3004B9AB4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/StudyApplication.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/StudyApplication.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2a68893..7957ec6 100644 --- a/StudyApplication.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/StudyApplication.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordBDHG/ResearchKit", "state" : { - "revision" : "64512d0a0a5cc3e9d5b3fc5217c54f11d0dc044c", - "version" : "2.2.28" + "revision" : "6b28cdf0d06c3d6e96b5585369968b85deac96e0", + "version" : "2.2.29" } }, { diff --git a/StudyApplication.xcodeproj/xcshareddata/xcschemes/StudyApplication.xcscheme b/StudyApplication.xcodeproj/xcshareddata/xcschemes/StudyApplication.xcscheme index 39251a3..98a0043 100644 --- a/StudyApplication.xcodeproj/xcshareddata/xcschemes/StudyApplication.xcscheme +++ b/StudyApplication.xcodeproj/xcshareddata/xcschemes/StudyApplication.xcscheme @@ -79,7 +79,7 @@ + isEnabled = "YES"> 3 * Constants.stepCountGoalIncrement else { + dailyStepCountGoalModule.stepCountGoal = 2 * Constants.stepCountGoalIncrement + print("Decrease GUARD ...") + return + } + dailyStepCountGoalModule.stepCountGoal -= Constants.stepCountGoalIncrement + print("Decrease ...") + }, + label: { + Image(systemName: "minus.circle") + .accessibilityLabel(Text("Decrease Step Count Goal by \(Constants.stepCountGoalIncrement)")) + } + ) + .font(.title) + .foregroundStyle(Color.accentColor) + .buttonStyle(.borderless) + } + + @ViewBuilder private var increaseDailyStepCountGoalButton: some View { + Button( + action: { + dailyStepCountGoalModule.stepCountGoal += Constants.stepCountGoalIncrement + print("Increase ...") + }, + label: { + Image(systemName: "plus.circle") + .accessibilityLabel(Text("Increase Step Count Goal by \(Constants.stepCountGoalIncrement)")) + } + ) + .font(.title) + .foregroundStyle(Color.accentColor) + .buttonStyle(.borderless) + } +} + + +#Preview { + let studyModule = StudyModule() + + + return NavigationStack { + List { + StudyApplicationListCard { + DailyStepCountGoal(study: studyModule.studies[0]) + } + } + .studyApplicationList() + .navigationTitle("Daily Step Goal") + } + .previewWith(standard: StudyApplicationStandard()) { + DailyStepCountGoalModule() + studyModule + } + .task { + try? await studyModule.enrollInStudy(study: studyModule.studies[0]) + } +} diff --git a/StudyApplication/Engagements/DailyStepCountGoal/DailyStepCountGoalModule.swift b/StudyApplication/Engagements/DailyStepCountGoal/DailyStepCountGoalModule.swift new file mode 100644 index 0000000..8f5fd20 --- /dev/null +++ b/StudyApplication/Engagements/DailyStepCountGoal/DailyStepCountGoalModule.swift @@ -0,0 +1,135 @@ +// +// This source file is part of the StudyApplication based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +import OSLog +import Spezi +import SpeziHealthKit +import SpeziLocalStorage + + +@Observable +class DailyStepCountGoalModule: Module, EnvironmentAccessible, DefaultInitializable { + private let logger = Logger(subsystem: "edu.stanford.spezi.studyapplication", category: "TodayStepCount") + private let healthStore = HKHealthStore() + + @ObservationIgnored @Dependency var localStorage: LocalStorage + @ObservationIgnored @Dependency var healthKit: HealthKit + + private var queryTask: Task? + private var dayChangedTask: Task? + private(set) var todayStepCount: Int = 0 + var stepCountGoal: Int = 10_000 { + didSet { + do { + try localStorage.store(stepCountGoal, storageKey: StorageKeys.dailyStepCountGoal) + } catch { + logger.error("Could not store enrolled studies.") + } + } + } + + + required init() { } + + + func configure() { + do { + if ProcessInfo.processInfo.isPreviewSimulator { + Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + self.todayStepCount += Int.random(in: 20...42) + } + } else { + #warning("We need to store the step count goal in Firebase and observe changes in the study app.") + self.stepCountGoal = try localStorage.read(storageKey: StorageKeys.dailyStepCountGoal) + } + } catch { + logger.info("Could not retrieve existing step count goal.") + } + + // We use observation tracking to observe healthKit.authorized. + // If the value is false, the query is not executed and the observation tracking will let us know if the value changed. + withContinousObservation(of: self.healthKit.authorized) { authorized in + if authorized { + self.executeStatisticsQuery() + } + } + } + + private func executeStatisticsQuery() { + guard healthKit.authorized else { + return + } + + if let queryTask { + queryTask.cancel() + } + + observeDayChanged() + + queryTask = Task { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: .now) + + guard let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) else { + logger.debug("Could not generate end of day for day: \(startOfDay). Will not execure query.") + return + } + + let stepsToday = HKSamplePredicate.quantitySample( + type: HKQuantityType(.stepCount), + predicate: HKQuery.predicateForSamples(withStart: startOfDay, end: endOfDay) + ) + + let stepsTodayQuery = HKStatisticsCollectionQueryDescriptor( + predicate: stepsToday, + options: .cumulativeSum, + anchorDate: startOfDay, + intervalComponents: DateComponents(day: 1) + ) + + do { + for try await results in stepsTodayQuery.results(for: healthStore) { + guard !Task.isCancelled else { + logger.debug("Task was cancelled, exeting results loop.") + return + } + + guard let result = results.statisticsCollection.statistics(for: startOfDay), + let stepCount = result.sumQuantity()?.doubleValue(for: HKUnit.count()) else { + logger.warning("Could not retrieve expected result from startistics collection.") + continue + } + + self.todayStepCount = Int(stepCount) + } + } catch { + logger.error("Could not execure HealthKit query: \(error)") + } + } + } + + private func observeDayChanged() { + guard dayChangedTask == nil else { + return + } + + dayChangedTask = Task { + for await _ in NotificationCenter.default.notifications(named: .NSCalendarDayChanged) { + logger.debug("Day did change.") + executeStatisticsQuery() + } + } + } + + + deinit { + dayChangedTask?.cancel() + queryTask?.cancel() + } +} diff --git a/StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift b/StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift new file mode 100644 index 0000000..c1b5c8e --- /dev/null +++ b/StudyApplication/Engagements/DailyStepCountGoal/Gauge.swift @@ -0,0 +1,160 @@ +// +// This source file is part of the StudyApplication based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +private struct GaugeRing: Shape { + private(set) var progress: CGFloat + + + var animatableData: CGFloat { + get { + progress + } + set { + progress = newValue + } + } + + + init(progress: CGFloat) { + self.progress = progress + } + + + func path(in rect: CGRect) -> Path { + Path { path in + path.addArc( + center: CGPoint(x: rect.width / 2, y: rect.height / 2), + radius: min(rect.width, rect.height) / 2, + startAngle: Angle(degrees: 0), + endAngle: Angle(degrees: progress * 360.0), + clockwise: false + ) + } + } +} + +struct Gauge: View { + let lineWidth: Double + let shaddowRadius: Double + let gradient: Gradient + let backgroundColor: Color + let progress: Double + + @State private var size: CGSize = .zero + + + private var shaddowOffset: CGPoint { + let angle = Angle(degrees: progress * 360.0).radians + return CGPoint(x: cos(angle) * shaddowRadius * 2.0, y: sin(angle) * shaddowRadius * 2.0) + } + + private var radius: Double { + (size.height / 2) - (lineWidth / 2) + } + + var body: some View { + ZStack { // swiftlint:disable:this closure_body_length + GeometryReader { proxy in + Circle() + .stroke(backgroundColor, lineWidth: lineWidth) + .padding(lineWidth / 2) + .onAppear { + self.size = proxy.size + } + } + GaugeRing(progress: progress) + .stroke( + AngularGradient( + gradient: gradient, + center: .center, + startAngle: .degrees(0), + endAngle: .degrees(progress * 360.0) + ), + style: StrokeStyle(lineWidth: 20, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .frame(width: size.width, height: size.height) + .padding(lineWidth / 2) + if progress < 1.0 - (lineWidth / (2 * radius * .pi)) { + Circle() + .frame(width: lineWidth) + .foregroundColor(gradient.stops.first?.color) + .offset(y: -(size.height / 2)) + } else { + let shaddowOffset = shaddowOffset + Circle() + .frame(width: lineWidth) + .foregroundColor(gradient.stops.last?.color) + .offset(y: -(size.height / 2)) + .rotationEffect(Angle.degrees(360 * Double(progress))) + .shadow( + color: .black.opacity(0.2), + radius: shaddowRadius, + x: shaddowOffset.x, + y: shaddowOffset.y + ) + } + } + .aspectRatio(1.0, contentMode: .fit) + .animation(.easeOut, value: progress) + } + + + init( + progress: Double, + gradient: Gradient, + backgroundColor: Color, + lineWidth: Double = 20, + shaddowRadius: Double = 4 + ) { + self.progress = progress + self.lineWidth = lineWidth + self.shaddowRadius = shaddowRadius + self.gradient = gradient + self.backgroundColor = backgroundColor + } + + init( + progress: Double, + color: Color = .accentColor, + lineWidth: Double = 20 + ) { + self.progress = progress + self.lineWidth = lineWidth + self.shaddowRadius = lineWidth / 5 + self.gradient = Gradient(colors: [color, color]) + self.backgroundColor = color.opacity(0.2) + } +} + + +#Preview { + Gauge(progress: 0.981, color: .red) + .padding() +} + +#Preview("Animation") { + struct PreviewWrapper: View { + @State var progress = 0.0 + + var body: some View { + Gauge(progress: progress) + .task { + Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + progress += Double.random(in: 0.0...0.05) + } + } + } + } + + return PreviewWrapper() + .padding() +} diff --git a/StudyApplication/Engagements/StudyEnrollment/StudyEnrollment.swift b/StudyApplication/Engagements/StudyEnrollment/StudyEnrollment.swift new file mode 100644 index 0000000..e12576f --- /dev/null +++ b/StudyApplication/Engagements/StudyEnrollment/StudyEnrollment.swift @@ -0,0 +1,78 @@ +// +// This source file is part of the StudyApplication based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct StudyEnrollment: View { + private let study: Study + @Environment(StudyModule.self) private var studyModule + @State private var startOfDay: Date = Calendar.current.startOfDay(for: .now) + + + private var daysEnrolled: Int { + guard let enrollmentDate = studyModule.studyState(for: study.id).enrolled else { + fatalError("For a study to be displaying the enrollment engagement it must be actively enrolled.") + } + + let enrollmentDistance = Calendar.current.dateComponents([.day], from: enrollmentDate, to: startOfDay) + + return enrollmentDistance.day ?? 0 + } + + var body: some View { + VStack { + StudyHeader(study: study) + Divider() + HStack(alignment: .firstTextBaseline, spacing: 32) { + informationSection(String(daysEnrolled), description: "Days in Study") + #warning("Might be cool to add a section about the completed tasks here as well.") + } + .padding(.horizontal) + } + .onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in + startOfDay = Calendar.current.startOfDay(for: .now) + } + } + + + init(study: Study) { + self.study = study + } + + + private func informationSection(_ value: String, description: String) -> some View { + VStack(alignment: .center) { + Text(value) + .font(.largeTitle.bold().monospacedDigit()) + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(minWidth: 0, maxWidth: .infinity) + } +} + + +#Preview { + let studyModule = StudyModule() + + return List { + StudyApplicationListCard { + StudyEnrollment(study: studyModule.studies[0]) + } + } + .studyApplicationList() + .previewWith(standard: StudyApplicationStandard()) { + studyModule + } + .task { + try? await studyModule.enrollInStudy(study: studyModule.studies[0]) + } +} diff --git a/StudyApplication/Helper/Bundle+Image.swift b/StudyApplication/Helper/Bundle+Image.swift deleted file mode 100644 index f46bc03..0000000 --- a/StudyApplication/Helper/Bundle+Image.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// This source file is part of the StudyApplication based on the Stanford Spezi Template Application project -// -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT -// - -import SwiftUI - - -extension Foundation.Bundle { - /// Loads an image from the `Bundle` instance. - /// - Parameters: - /// - name: The name of the image. - /// - fileExtension: The file extension of the image. - /// - Returns: Returns the `UIImage` loaded from the `Bundle` instance. - func image(withName name: String, fileExtension: String) -> UIImage { - guard let resourceURL = self.url(forResource: name, withExtension: fileExtension) else { - fatalError("Could not find the file \"\(name).\(fileExtension)\" in the bundle.") - } - - guard let resourceData = try? Data(contentsOf: resourceURL), - let image = UIImage(data: resourceData) else { - fatalError("Decode the image named \"\(name).\(fileExtension)\"") - } - - return image - } -} diff --git a/StudyApplication/Helper/Observation+Continous.swift b/StudyApplication/Helper/Observation+Continous.swift new file mode 100644 index 0000000..c2beeb6 --- /dev/null +++ b/StudyApplication/Helper/Observation+Continous.swift @@ -0,0 +1,20 @@ +// +// This source file is part of the Stanford Spezi Study Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Observation + + +func withContinousObservation(of value: @escaping @autoclosure () -> T, execute: @escaping (T) -> Void) { + withObservationTracking { + execute(value()) + } onChange: { + Task { @MainActor in + withContinousObservation(of: value(), execute: execute) + } + } +} diff --git a/StudyApplication/Home/Home.swift b/StudyApplication/Home/Home.swift new file mode 100644 index 0000000..d9a6263 --- /dev/null +++ b/StudyApplication/Home/Home.swift @@ -0,0 +1,89 @@ +// +// This source file is part of the StudyApplication based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct Home: View { + @Environment(StudyModule.self) var studyModule + + + var body: some View { + NavigationStack { + Group { + if let enrolledStudy = studyModule.enrolledStudy { + List { + enrolledStudySection(enrolledStudy: enrolledStudy) + } + .studyApplicationList() + } else { + ContentUnavailableView( + label: { + Label("No Enrolled Study", systemImage: "list.clipboard") + }, + description: { + Text( + """ + You are not enrolled in a study. + Please navigate to the Study tab to enroll in a study. + """ + ) + } + ) + } + } + .navigationTitle("Home") + } + } + + + @ViewBuilder + private func enrolledStudySection(enrolledStudy: Study) -> some View { + Section( + content: { + if enrolledStudy.engagements.isEmpty { + StudyApplicationListCard { + Study.Engagement.studyEnrollment.view(correlatedToStudy: enrolledStudy) + } + } else { + ForEach(enrolledStudy.engagements) { engagement in + StudyApplicationListCard { + engagement.view(correlatedToStudy: enrolledStudy) + } + } + } + }, + header: { + Text(enrolledStudy.title) + .studyApplicationHeaderStyle() + } + ) + } +} + + +#Preview("Mock Study") { + let studyModule = StudyModule() + + return Home() + .previewWith(standard: StudyApplicationStandard()) { + studyModule + DailyStepCountGoalModule() + } + .task { + try? await studyModule.enrollInStudy(study: studyModule.studies[0]) + } +} + +#Preview("No Enrolled Study") { + Home() + .previewWith(standard: StudyApplicationStandard()) { + StudyModule() + DailyStepCountGoalModule() + } +} diff --git a/StudyApplication/Home.swift b/StudyApplication/MainView.swift similarity index 83% rename from StudyApplication/Home.swift rename to StudyApplication/MainView.swift index b352920..88a5e7a 100644 --- a/StudyApplication/Home.swift +++ b/StudyApplication/MainView.swift @@ -9,8 +9,9 @@ import SwiftUI -struct HomeView: View { +struct MainView: View { enum Tabs: String { + case home case studies case schedule } @@ -27,6 +28,11 @@ struct HomeView: View { var body: some View { TabView(selection: $selectedTab) { + Home() + .tag(Tabs.home) + .tabItem { + Label("Home", systemImage: "house.fill") + } StudiesView() .tag(Tabs.studies) .tabItem { @@ -44,6 +50,6 @@ struct HomeView: View { #if DEBUG #Preview { - HomeView() + MainView() } #endif diff --git a/StudyApplication/Onboarding/InterestingModules.swift b/StudyApplication/Onboarding/InterestingModules.swift index a7dabca..67dcd53 100644 --- a/StudyApplication/Onboarding/InterestingModules.swift +++ b/StudyApplication/Onboarding/InterestingModules.swift @@ -45,10 +45,8 @@ struct InterestingModules: View { } -#if DEBUG #Preview { OnboardingStack { InterestingModules() } } -#endif diff --git a/StudyApplication/Onboarding/OnboardingFlow.swift b/StudyApplication/Onboarding/OnboardingFlow.swift index 6b41bd8..a5f02f5 100644 --- a/StudyApplication/Onboarding/OnboardingFlow.swift +++ b/StudyApplication/Onboarding/OnboardingFlow.swift @@ -41,9 +41,7 @@ struct OnboardingFlow: View { } -#if DEBUG #Preview { OnboardingFlow() .previewWith(standard: StudyApplicationStandard()) {} } -#endif diff --git a/StudyApplication/Onboarding/Welcome.swift b/StudyApplication/Onboarding/Welcome.swift index 81316ee..abbae43 100644 --- a/StudyApplication/Onboarding/Welcome.swift +++ b/StudyApplication/Onboarding/Welcome.swift @@ -58,10 +58,8 @@ struct Welcome: View { } -#if DEBUG #Preview { OnboardingStack { Welcome() } } -#endif diff --git a/StudyApplication/Resources/Localizable.xcstrings b/StudyApplication/Resources/Localizable.xcstrings index 2eef17c..5e12966 100644 --- a/StudyApplication/Resources/Localizable.xcstrings +++ b/StudyApplication/Resources/Localizable.xcstrings @@ -3,6 +3,12 @@ "strings" : { "" : { + }, + "%@" : { + + }, + "%lld" : { + }, "ACCOUNT_NEXT" : { "extractionState" : "stale", @@ -99,6 +105,9 @@ } } } + }, + "Content ..." : { + }, "Contribute to Science" : { @@ -132,6 +141,18 @@ } } } + }, + "Daily Step Count Goal" : { + + }, + "Daily Step Count Goal: %lld" : { + + }, + "Daily Step Goal" : { + + }, + "Decrease Step Count Goal by %lld" : { + }, "Enroll" : { @@ -156,9 +177,15 @@ }, "HealthKit Access" : { + }, + "Home" : { + }, "If you are alredy enrolled in a research study, find it on the application home screen after the onboarding and enter your inviation code." : { + }, + "Increase Step Count Goal by %lld" : { + }, "Invitation Code" : { @@ -175,6 +202,18 @@ } } } + }, + "List With Sections" : { + + }, + "List Without Sections" : { + + }, + "No Enrolled Study" : { + + }, + "No Studies" : { + }, "No Tasks" : { @@ -216,6 +255,9 @@ }, "Single application to enroll into multiple studies." : { + }, + "Steps Today" : { + }, "Studies" : { @@ -257,6 +299,9 @@ }, "The Spezi Study Application currently only allows enrolling in a single study at the same time." : { + }, + "There are no studies to enroll to at the moment.\nPlease check back in the future; we add studies on a rolling basis." : { + }, "Unknown Invitation Code Error" : { @@ -269,6 +314,9 @@ }, "You are all set. Please follow the Study Schedule and consider enabeling notifications to get informed about any upcoming tasks." : { + }, + "You are not enrolled in a study.\nPlease navigate to the Study tab to enroll in a study." : { + }, "You will need to provide the application permission to get access to the data that the study collects." : { diff --git a/StudyApplication/Schedule/EventContextView.swift b/StudyApplication/Schedule/EventContextView.swift index 2fc245f..ef6cf21 100644 --- a/StudyApplication/Schedule/EventContextView.swift +++ b/StudyApplication/Schedule/EventContextView.swift @@ -39,7 +39,7 @@ struct EventContextView: View { Divider() Text(eventContext.task.description) .font(.callout) - if !eventContext.event.complete { + if !eventContext.event.complete && eventContext.event.due { Text(eventContext.task.context.actionType) .frame(maxWidth: .infinity, minHeight: 50) .foregroundColor(.white) @@ -63,21 +63,24 @@ struct EventContextView: View { } -#if DEBUG #Preview(traits: .sizeThatFitsLayout) { - guard let task = Study.vascTracStanford.tasks.first else { + guard let task = Study.vascTracStanford.tasks.first, + let event = task.events(from: .now.addingTimeInterval(-60 * 60 * 24)).first else { fatalError("Could not load task") } - return EventContextView( - eventContext: EventContext( - // We use a force unwrap in the preview as we can not recover from an error here - // and the code will never end up in a production environment. - // swiftlint:disable:next force_unwrapping - event: task.events(from: .now.addingTimeInterval(-60 * 60 * 24)).first!, - task: task + return List { + Section( + content: { + ForEach(0..<2) { _ in + EventContextView(eventContext: EventContext(event: event, task: task)) + } + .listRowSeparator(.hidden) + }, + header: { + Text("\(.now, style: .date)") + } ) - ) - .padding() + } + .listStyle(.plain) } -#endif diff --git a/StudyApplication/Schedule/ScheduleView.swift b/StudyApplication/Schedule/ScheduleView.swift index 4669614..8d38322 100644 --- a/StudyApplication/Schedule/ScheduleView.swift +++ b/StudyApplication/Schedule/ScheduleView.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import OrderedCollections import SpeziQuestionnaire import SpeziScheduler import SwiftUI @@ -14,30 +15,40 @@ import SwiftUI struct ScheduleView: View { @Environment(StudyApplicationStandard.self) private var standard @Environment(StudyApplicationScheduler.self) private var scheduler - @State private var eventContextsByDate: [Date: [EventContext]] = [:] + @State private var presentedContext: EventContext? - - @Binding private var presentingAccount: Bool - private var startOfDays: [Date] { - Array(eventContextsByDate.keys).sorted() + private var eventContextsByDate: OrderedDictionary { + let eventContexts = scheduler.tasks.flatMap { task in + task + .events( + from: .distantPast, + to: .numberOfEvents(100) + ) + .map { event in + EventContext(event: event, task: task) + } + } + .sorted() + + return OrderedDictionary(grouping: eventContexts) { eventContext in + Calendar.current.startOfDay(for: eventContext.event.scheduledAt) + } } var body: some View { NavigationStack { Group { - if startOfDays.isEmpty { + let eventContextsByDate = eventContextsByDate + if eventContextsByDate.isEmpty { emptyListView } else { - listView + listView(eventContextsByDate: eventContextsByDate) } } - .onChange(of: scheduler.tasks, initial: true) { - calculateEventContextsByDate() - } .sheet(item: $presentedContext) { presentedContext in destination(withContext: presentedContext) } @@ -51,21 +62,10 @@ struct ScheduleView: View { } description: { Text("No tasks scheduled for your enrolled studies.") } - } - - @ViewBuilder private var listView: some View { - List(startOfDays, id: \.timeIntervalSinceNow) { startOfDay in - Section(format(startOfDay: startOfDay)) { - ForEach(eventContextsByDate[startOfDay] ?? [], id: \.event) { eventContext in - EventContextView(eventContext: eventContext) - .onTapGesture { - if !eventContext.event.complete && eventContext.event.due { - presentedContext = eventContext - } - } - } + .background { + Color(.systemGroupedBackground) + .edgesIgnoringSafeArea(.all) } - } } @@ -74,6 +74,31 @@ struct ScheduleView: View { } + @ViewBuilder + private func listView(eventContextsByDate: OrderedDictionary) -> some View { + List(eventContextsByDate.keys, id: \.timeIntervalSinceNow) { startOfDay in + Section( + content: { + ForEach(eventContextsByDate[startOfDay] ?? [], id: \.event) { eventContext in + StudyApplicationListCard { + EventContextView(eventContext: eventContext) + .onTapGesture { + if !eventContext.event.complete && eventContext.event.due { + presentedContext = eventContext + } + } + } + } + }, + header: { + Text("\(startOfDay, style: .date)") + .studyApplicationHeaderStyle() + } + ) + } + .studyApplicationList() + } + private func destination(withContext eventContext: EventContext) -> some View { @ViewBuilder var destination: some View { switch eventContext.task.context { @@ -92,42 +117,24 @@ struct ScheduleView: View { } return destination } +} + + +#Preview("Mock Study") { + let studyModule = StudyModule() - - private func format(startOfDay: Date) -> String { - let dateFormatter = DateFormatter() - dateFormatter.dateStyle = .long - dateFormatter.timeStyle = .none - return dateFormatter.string(from: startOfDay) - } - - private func calculateEventContextsByDate() { - let eventContexts = scheduler.tasks.flatMap { task in - task - .events( - from: .distantPast, - to: .numberOfEvents(100) - ) - .map { event in - EventContext(event: event, task: task) - } + return ScheduleView(presentingAccount: .constant(false)) + .previewWith(standard: StudyApplicationStandard()) { + studyModule } - .sorted() - - let newEventContextsByDate = Dictionary(grouping: eventContexts) { eventContext in - Calendar.current.startOfDay(for: eventContext.event.scheduledAt) + .task { + try? await studyModule.enrollInStudy(study: studyModule.studies[0]) } - - eventContextsByDate = newEventContextsByDate - } } - -#if DEBUG -#Preview("ScheduleView") { +#Preview("No Tasks") { ScheduleView(presentingAccount: .constant(false)) .previewWith(standard: StudyApplicationStandard()) { StudyApplicationScheduler() } } -#endif diff --git a/StudyApplication/SharedContext/FeatureFlags.swift b/StudyApplication/SharedContext/FeatureFlags.swift index f36557a..727a454 100644 --- a/StudyApplication/SharedContext/FeatureFlags.swift +++ b/StudyApplication/SharedContext/FeatureFlags.swift @@ -6,6 +6,10 @@ // SPDX-License-Identifier: MIT // +import Foundation +import SpeziFoundation + + /// A collection of feature flags for the Spezi Study Application. enum FeatureFlags { /// Skips the onboarding flow to enable easier development of features in the application and to allow UI tests to skip the onboarding flow. @@ -13,7 +17,7 @@ enum FeatureFlags { /// Always show the onboarding when the application is launched. Makes it easy to modify and test the onboarding flow without the need to manually remove the application or reset the simulator. static let showOnboarding = CommandLine.arguments.contains("--showOnboarding") /// Disables the Firebase interactions, including the login/sign-up step and the Firebase Firestore upload. - static let disableFirebase = CommandLine.arguments.contains("--disableFirebase") + static let disableFirebase = CommandLine.arguments.contains("--disableFirebase") || ProcessInfo.processInfo.isPreviewSimulator #if targetEnvironment(simulator) /// Defines if the application should connect to the local firebase emulator. Always set to true when using the iOS simulator. static let useFirebaseEmulator = true diff --git a/StudyApplication/SharedContext/StorageKeys.swift b/StudyApplication/SharedContext/StorageKeys.swift index 89d21f3..36cc5cb 100644 --- a/StudyApplication/SharedContext/StorageKeys.swift +++ b/StudyApplication/SharedContext/StorageKeys.swift @@ -23,4 +23,9 @@ enum StorageKeys { // MARK: - Study /// The currently enrolled studies static let currentlyEnrolledStudies = "studies.currentlyEnrolled" + + + // MARK: - Engagements + /// Daily step count goal + static let dailyStepCountGoal = "engagement.dailyStepCountGoal" } diff --git a/StudyApplication/SharedContext/StudyApplicationListCard.swift b/StudyApplication/SharedContext/StudyApplicationListCard.swift new file mode 100644 index 0000000..28537ba --- /dev/null +++ b/StudyApplication/SharedContext/StudyApplicationListCard.swift @@ -0,0 +1,101 @@ +// +// This source file is part of the StudyApplication based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct StudyApplicationListCard: View { + var content: () -> Content + + + var body: some View { + content() + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background { + Color(uiColor: .systemBackground) + .ignoresSafeArea() + } + .clipShape(RoundedRectangle(cornerRadius: 10.0)) + .listRowBackground(Color(.systemGroupedBackground)) + .listRowSeparator(.hidden) + } + + + init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } +} + + +extension List { + func studyApplicationList() -> some View { + self + .listRowSpacing(-8) + .listSectionSpacing(-16) + .listStyle(.plain) + .background { + Color(.systemGroupedBackground) + .edgesIgnoringSafeArea(.all) + } + } +} + +extension Text { + func studyApplicationHeaderStyle() -> some View { + self + .font(.title2.bold()) + .foregroundStyle(Color(.label)) + } +} + + +#Preview("Sections") { + NavigationStack { + List { + ForEach(0..<2) { _ in + Section( + content: { + ForEach(0..<2) { _ in + StudyApplicationListCard { + HStack { + Text("Content ...") + Spacer() + } + } + } + .listRowSeparator(.hidden) + }, + header: { + Text("\(.now, style: .date)") + .studyApplicationHeaderStyle() + } + ) + } + } + .studyApplicationList() + .navigationTitle("List With Sections") + } +} + +#Preview("No Sections") { + NavigationStack { + List { + ForEach(0..<2) { _ in + StudyApplicationListCard { + HStack { + Text("Content ...") + Spacer() + } + } + } + } + .studyApplicationList() + .navigationTitle("List Without Sections") + } +} diff --git a/StudyApplication/Studies/MockStudy/MockStudy.swift b/StudyApplication/Studies/MockStudy/MockStudy.swift new file mode 100644 index 0000000..b7a7b7b --- /dev/null +++ b/StudyApplication/Studies/MockStudy/MockStudy.swift @@ -0,0 +1,82 @@ +// +// This source file is part of the Stanford Spezi Study Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import ModelsR4 +import SpeziHealthKit +import SpeziScheduler + + +// swiftlint:disable line_length force_unwrapping +extension Study { + private static var mockDescription: String { + "Our mission is to advance digital health research and applications by fostering an accessible health ecosystem at the Stanford Byers Center for Biodesign. We develop, implement, and investigate digital health solutions that improve health journeys. Our flagship project, Stanford Spezi, is an open-source framework for building modular, interoperable, and scalable digital health applications." + } + + private static var mockTasks: [SpeziScheduler.Task] { + [ + Task( + title: "Example Title", + description: "This is a bit longer example description that is rendered with a task.", + schedule: Schedule( + start: .now, + repetition: .matching(DateComponents(hour: 23, minute: 59)), + end: .numberOfEvents(1) + ), + notifications: true, + context: .questionnaire(Questionnaire.phq9) + ), + Task( + title: "An Other Example Title", + description: "This is a bit longer example description that is rendered with a task.", + schedule: Schedule( + start: .now, + repetition: .matching(DateComponents(hour: 23, minute: 59)), + end: .numberOfEvents(1) + ), + notifications: true, + context: .questionnaire(Questionnaire.phq9) + ) + ] + } + + private static var mockHealthKitAccess: Study.HealthKitAccess { + Study.HealthKitAccess( + usageDescription: "The Mock Study requires access to your step count to get a complete picture about your health status." + ) { + CollectSamples( + [ + HKQuantityType(.stepCount) + ], + deliverySetting: .background(.automatic) + ) + } + } + + static var mockStudy: Study { + Study( + id: UUID(uuidString: "02E8B3E6-C4E3-4CD9-B3F2-8BF257825470")!, + title: "Mock Study", + titleImage: URL(string: "https://bdh.sites.stanford.edu/sites/g/files/sbiybj28491/files/styles/breakpoint_2xl_2x/public/media/image/photo-epel-stanford-campus.jpg.webp?itok=StftZAgI")!, + organization: Organization( + title: "Stanford Byers Center for Biodesing", + logo: URL(string: "https://pbs.twimg.com/profile_images/1372580239805616129/OlR0Y3Ae_400x400.jpg")! + ), + description: mockDescription, + onboardingMechanism: InviationCodeStudyOnboardingMechanism(), + consentDocument: "This is an example consent", + healthKit: mockHealthKitAccess, + notificationDescription: "The Mock Study wants to send you notifications to remind you about answering your questinnaires.", + tasks: mockTasks, + engagements: [ + .studyEnrollment, + .dailyStepCountGoal + ] + ) + } +} diff --git a/StudyApplication/Studies/VascTrac/VascTracStudy.swift b/StudyApplication/Studies/VascTrac/VascTracStudy.swift index 31c5aa5..955b7ae 100644 --- a/StudyApplication/Studies/VascTrac/VascTracStudy.swift +++ b/StudyApplication/Studies/VascTrac/VascTracStudy.swift @@ -74,8 +74,8 @@ extension Study { ] } - private static var vascTracHealthKitAccess: StudyHealthKitAccess { - StudyHealthKitAccess( + private static var vascTracHealthKitAccess: Study.HealthKitAccess { + Study.HealthKitAccess( usageDescription: "VascTrac requires access to your step count, flight climed, and elements like the walking steadiness score to get a complete picture about your procedure preparation." ) { CollectSamples( @@ -110,7 +110,11 @@ extension Study { onboardingMechanism: InviationCodeStudyOnboardingMechanism(), healthKit: vascTracHealthKitAccess, notificationDescription: "Vasc Track wants to send you notifications to remind you about answering your questinnaires.", - tasks: vascTracTasks + tasks: vascTracTasks, + engagements: [ + .studyEnrollment, + .dailyStepCountGoal + ] ) } @@ -127,12 +131,16 @@ extension Study { onboardingMechanism: InviationCodeStudyOnboardingMechanism(), healthKit: vascTracHealthKitAccess, notificationDescription: "Vasc Track wants to send you notifications to remind you about answering your questinnaires.", - tasks: vascTracTasks + tasks: vascTracTasks, + engagements: [ + .studyEnrollment, + .dailyStepCountGoal + ] ) } - static func task(forQuestionnaire questionnaire: Questionnaire, title: String, week: Int) -> SpeziScheduler.Task { + private static func task(forQuestionnaire questionnaire: Questionnaire, title: String, week: Int) -> SpeziScheduler.Task { let hour = week == 0 ? 0 : 7 let date: Date @@ -145,7 +153,7 @@ extension Study { return Task( title: "\(title) - Week \(week)", - description: "Plase fill out the \(title) questionnaire on week \(week).", + description: "Please fill out the \(title) questionnaire on week \(week).", schedule: Schedule( start: date, repetition: .matching(DateComponents(hour: hour, minute: 0)), diff --git a/StudyApplication/Study/Modules/Engagement.swift b/StudyApplication/Study/Modules/Engagement.swift new file mode 100644 index 0000000..01cec02 --- /dev/null +++ b/StudyApplication/Study/Modules/Engagement.swift @@ -0,0 +1,33 @@ +// +// This source file is part of the Stanford Spezi Study Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +extension Study { + enum Engagement: String, Codable, Identifiable { + case studyEnrollment + case dailyStepCountGoal + + + var id: RawValue { + rawValue + } + + + @ViewBuilder + func view(correlatedToStudy study: Study) -> some View { + switch self { + case .dailyStepCountGoal: + DailyStepCountGoal(study: study) + case .studyEnrollment: + StudyEnrollment(study: study) + } + } + } +} diff --git a/StudyApplication/Study/Modules/HealthKitAccess.swift b/StudyApplication/Study/Modules/HealthKitAccess.swift new file mode 100644 index 0000000..1264a31 --- /dev/null +++ b/StudyApplication/Study/Modules/HealthKitAccess.swift @@ -0,0 +1,44 @@ +// +// This source file is part of the Stanford Spezi Study Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SpeziHealthKit + + +extension Study { + struct HealthKitAccess: Codable { + enum CodingKeys: CodingKey { + case usageDescription + } + + let usageDescription: String + let healthKitDescriptions: [HealthKitDataSourceDescription] + + + init( + usageDescription: String, + @HealthKitDataSourceDescriptionBuilder _ healthKitDataSourceDescriptions: () -> [HealthKitDataSourceDescription] + ) { + self.usageDescription = usageDescription + self.healthKitDescriptions = healthKitDataSourceDescriptions() + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.usageDescription = try container.decode(String.self, forKey: .usageDescription) +#warning("It would be nescessary to make HealthKitDataSourceDescription Codable to ensure that we can load them from firebase.") + self.healthKitDescriptions = [] + } + + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.usageDescription, forKey: .usageDescription) + } + } +} diff --git a/StudyApplication/Study/Organization.swift b/StudyApplication/Study/Modules/Organization.swift similarity index 100% rename from StudyApplication/Study/Organization.swift rename to StudyApplication/Study/Modules/Organization.swift diff --git a/StudyApplication/Study/Modules/State.swift b/StudyApplication/Study/Modules/State.swift new file mode 100644 index 0000000..03a2359 --- /dev/null +++ b/StudyApplication/Study/Modules/State.swift @@ -0,0 +1,29 @@ +// +// This source file is part of the Stanford Spezi Study Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +extension Study { + struct State: Codable, Identifiable { + var id: Study.ID { + studyId + } + + let studyId: Study.ID + let enrolled: Date? + let finished: Date? + + + init(studyId: Study.ID, enrolled: Date? = .now) { + self.studyId = studyId + self.enrolled = enrolled + self.finished = nil + } + } +} diff --git a/StudyApplication/Study/Study.swift b/StudyApplication/Study/Modules/Study.swift similarity index 87% rename from StudyApplication/Study/Study.swift rename to StudyApplication/Study/Modules/Study.swift index 8fedbbd..1f69b0b 100644 --- a/StudyApplication/Study/Study.swift +++ b/StudyApplication/Study/Modules/Study.swift @@ -22,6 +22,7 @@ struct Study: Codable, Identifiable { case healthKit case notificationDescription case tasks + case engagements } @@ -32,9 +33,10 @@ struct Study: Codable, Identifiable { let description: String let onboardingMechanism: any StudyOnboardingMechanism let consentDocument: String? - let healthKit: StudyHealthKitAccess? + let healthKit: Study.HealthKitAccess? let notificationDescription: String? let tasks: [Task] + let engagements: [Engagement] init( @@ -45,9 +47,10 @@ struct Study: Codable, Identifiable { description: String, onboardingMechanism: any StudyOnboardingMechanism, consentDocument: String? = nil, - healthKit: StudyHealthKitAccess? = nil, + healthKit: Study.HealthKitAccess? = nil, notificationDescription: String? = nil, - tasks: [Task] = [] + tasks: [Task] = [], + engagements: [Engagement] = [] ) { self.id = id self.title = title @@ -59,6 +62,7 @@ struct Study: Codable, Identifiable { self.healthKit = healthKit self.notificationDescription = notificationDescription self.tasks = tasks + self.engagements = engagements } init(from decoder: Decoder) throws { @@ -81,9 +85,10 @@ struct Study: Codable, Identifiable { } self.consentDocument = try container.decodeIfPresent(String.self, forKey: .consentDocument) - self.healthKit = try container.decodeIfPresent(StudyHealthKitAccess.self, forKey: .healthKit) + self.healthKit = try container.decodeIfPresent(Study.HealthKitAccess.self, forKey: .healthKit) self.notificationDescription = try container.decodeIfPresent(String.self, forKey: .notificationDescription) self.tasks = try container.decodeIfPresent([Task].self, forKey: .tasks) ?? [] + self.engagements = try container.decodeIfPresent([Engagement].self, forKey: .engagements) ?? [] } @@ -99,5 +104,6 @@ struct Study: Codable, Identifiable { try container.encode(self.healthKit, forKey: .healthKit) try container.encode(self.notificationDescription, forKey: .notificationDescription) try container.encode(self.tasks, forKey: .tasks) + try container.encode(self.engagements, forKey: .engagements) } } diff --git a/StudyApplication/Study/StudiesView.swift b/StudyApplication/Study/StudiesView.swift deleted file mode 100644 index 592dbc8..0000000 --- a/StudyApplication/Study/StudiesView.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// This source file is part of the Stanford Spezi Study Application project -// -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT -// - -import SwiftUI - - -struct StudiesView: View { - @Environment(StudyModule.self) var studyModule - - - var body: some View { - NavigationStack { - ScrollView(.vertical) { - ForEach(studyModule.studies) { study in - StudyView(study: study) - .shadow(radius: 10) - .padding() - } - } - .navigationTitle("Studies") - } - } -} diff --git a/StudyApplication/Study/StudyHealthKitAccess.swift b/StudyApplication/Study/StudyHealthKitAccess.swift deleted file mode 100644 index cf21d05..0000000 --- a/StudyApplication/Study/StudyHealthKitAccess.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// This source file is part of the Stanford Spezi Study Application project -// -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT -// - -import Foundation -import SpeziHealthKit - - -struct StudyHealthKitAccess: Codable { - enum CodingKeys: CodingKey { - case usageDescription - } - - let usageDescription: String - let healthKitDescriptions: [HealthKitDataSourceDescription] - - - init( - usageDescription: String, - @HealthKitDataSourceDescriptionBuilder _ healthKitDataSourceDescriptions: () -> [HealthKitDataSourceDescription] - ) { - self.usageDescription = usageDescription - self.healthKitDescriptions = healthKitDataSourceDescriptions() - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.usageDescription = try container.decode(String.self, forKey: .usageDescription) - #warning("It would be nescessary to make HealthKitDataSourceDescription Codable to ensure that we can load them from firebase.") - self.healthKitDescriptions = [] - } - - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(self.usageDescription, forKey: .usageDescription) - } -} diff --git a/StudyApplication/Study/StudyModule.swift b/StudyApplication/Study/StudyModule.swift index 3fc6a60..c9da781 100644 --- a/StudyApplication/Study/StudyModule.swift +++ b/StudyApplication/Study/StudyModule.swift @@ -19,17 +19,24 @@ class StudyModule: Module, EnvironmentAccessible, DefaultInitializable { @ObservationIgnored @Dependency private var healthKit: HealthKit @ObservationIgnored @Dependency private var scheduler: StudyApplicationScheduler - let studies: [Study] = [Study.vascTracPaloAltoVA, Study.vascTracStanford] - private var studyState: [StudyState] = [] { + let studies: [Study] = { + if ProcessInfo.processInfo.isPreviewSimulator { + [Study.mockStudy] + } else { + [Study.vascTracPaloAltoVA, Study.vascTracStanford] + } + }() + private var states: [Study.State] = [] { didSet { do { - try localStorage.store(studyState, storageKey: StorageKeys.currentlyEnrolledStudies) + try localStorage.store(states, storageKey: StorageKeys.currentlyEnrolledStudies) } catch { logger.error("Could not store enrolled studies.") } } } + var enrolledStudy: Study? { #warning( """ @@ -37,7 +44,7 @@ class StudyModule: Module, EnvironmentAccessible, DefaultInitializable { Multiplexing in the standard needs to be more complex based on multiple studies. """ ) - guard let enrolledStudy = studyState.first(where: { $0.enrolled != nil && $0.finished == nil }) else { + guard let enrolledStudy = states.first(where: { $0.enrolled != nil && $0.finished == nil }) else { return nil } @@ -55,10 +62,13 @@ class StudyModule: Module, EnvironmentAccessible, DefaultInitializable { func configure() { do { - self.studyState = try localStorage.read(storageKey: StorageKeys.currentlyEnrolledStudies) + if !ProcessInfo.processInfo.isPreviewSimulator { + #warning("We need to store the study state in Firebase and observe changes in the study app.") + self.states = try localStorage.read(storageKey: StorageKeys.currentlyEnrolledStudies) + } } catch { logger.info("Could not retrieve existing enrolled studies.") - self.studyState = [] + self.states = [] } if let enrolledStudy { @@ -68,7 +78,7 @@ class StudyModule: Module, EnvironmentAccessible, DefaultInitializable { func enrollInStudy(study: Study) async throws { - guard !studyState.contains(where: { $0.enrolled != nil && $0.finished == nil }) else { + guard !states.contains(where: { $0.enrolled != nil && $0.finished == nil }) else { throw StudyError.canOnlyEnrollInOneStudy } @@ -78,9 +88,12 @@ class StudyModule: Module, EnvironmentAccessible, DefaultInitializable { await scheduler.schedule(task: task) } - studyState.append(StudyState(studyId: study.id, enrolled: .now)) + states.append(Study.State(studyId: study.id, enrolled: .now)) } + func studyState(for studyId: Study.ID) -> Study.State { + states.first(where: { $0.id == studyId }) ?? Study.State(studyId: studyId) + } private func executeHealthKitQueries(for study: Study) { for healthKitDescription in study.healthKit?.healthKitDescriptions ?? [] { diff --git a/StudyApplication/Study/StudyState.swift b/StudyApplication/Study/StudyState.swift deleted file mode 100644 index 7577e84..0000000 --- a/StudyApplication/Study/StudyState.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// This source file is part of the Stanford Spezi Study Application project -// -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT -// - -import Foundation - - -struct StudyState: Codable, Identifiable { - var id: Study.ID { - studyId - } - - let studyId: Study.ID - let enrolled: Date? - let finished: Date? - - - init(studyId: Study.ID, enrolled: Date? = .now) { - self.studyId = studyId - self.enrolled = enrolled - self.finished = nil - } -} diff --git a/StudyApplication/Study/Views/StudiesView.swift b/StudyApplication/Study/Views/StudiesView.swift new file mode 100644 index 0000000..d634878 --- /dev/null +++ b/StudyApplication/Study/Views/StudiesView.swift @@ -0,0 +1,61 @@ +// +// This source file is part of the Stanford Spezi Study Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct StudiesView: View { + @Environment(StudyModule.self) var studyModule + + + var body: some View { + NavigationStack { + Group { + if studyModule.studies.isEmpty { + ContentUnavailableView( + label: { + Label("No Studies", systemImage: "list.clipboard") + }, + description: { + Text( + """ + There are no studies to enroll to at the moment. + Please check back in the future; we add studies on a rolling basis. + """ + ) + } + ) + } else { + List { + ForEach(studyModule.studies) { study in + StudyApplicationListCard { + StudyView(study: study) + .padding(.horizontal, -16) + .padding(.vertical, -8) + } + } + } + .studyApplicationList() + } + } + .navigationTitle("Studies") + .background { + Color(.systemGroupedBackground) + .ignoresSafeArea(.all) + } + } + } +} + + +#Preview { + StudiesView() + .previewWith(standard: StudyApplicationStandard()) { + StudyModule() + } +} diff --git a/StudyApplication/Study/Views/StudyHeader.swift b/StudyApplication/Study/Views/StudyHeader.swift new file mode 100644 index 0000000..f0cf6dc --- /dev/null +++ b/StudyApplication/Study/Views/StudyHeader.swift @@ -0,0 +1,47 @@ +// +// This source file is part of the Stanford Spezi Study Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct StudyHeader: View { + private let study: Study + + + var body: some View { + HStack { + VStack(alignment: .leading) { + Text(study.title) + .font(.title2) + .bold() + Text(study.organization.title) + .foregroundStyle(.secondary) + } + Spacer() + HStack(alignment: .top) { + AsyncImage(url: study.organization.logo) { image in + image + .resizable() + .scaledToFit() + } placeholder: { + ProgressView() + } + .frame(width: 50) + } + } + } + + + init(study: Study) { + self.study = study + } +} + +#Preview { + StudyHeader(study: StudyModule().studies[0]) +} diff --git a/StudyApplication/Study/Views/StudyImage.swift b/StudyApplication/Study/Views/StudyImage.swift new file mode 100644 index 0000000..1d037bb --- /dev/null +++ b/StudyApplication/Study/Views/StudyImage.swift @@ -0,0 +1,44 @@ +// +// This source file is part of the Stanford Spezi Study Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct StudyImage: View { + private let study: Study + + + var body: some View { + AsyncImage(url: study.titleImage) { image in + image + .resizable() + .scaledToFill() + .frame(height: 160) + } placeholder: { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + .frame(height: 160) + .background { + Color(.systemGray5) + } + .clipShape(Rectangle()) + } + + + init(study: Study) { + self.study = study + } +} + +#Preview { + StudyImage(study: StudyModule().studies[0]) +} diff --git a/StudyApplication/Study/StudyView.swift b/StudyApplication/Study/Views/StudyView.swift similarity index 55% rename from StudyApplication/Study/StudyView.swift rename to StudyApplication/Study/Views/StudyView.swift index 4fa640a..b512e83 100644 --- a/StudyApplication/Study/StudyView.swift +++ b/StudyApplication/Study/Views/StudyView.swift @@ -7,7 +7,6 @@ // import SpeziHealthKit -import SpeziLocalStorage import SpeziOnboarding import SwiftUI @@ -24,7 +23,7 @@ struct StudyView: View { var body: some View { VStack(spacing: 0) { - imageView + StudyImage(study: study) studyDescription Divider() enrollSection @@ -33,53 +32,14 @@ struct StudyView: View { Color(uiColor: .systemBackground) .ignoresSafeArea() } - .clipShape(RoundedRectangle(cornerRadius: 25.0)) .sheet(isPresented: $showEnrollSheet) { StudyOnboardingFlow(study: study, studyOnboardingComplete: !$showEnrollSheet) } } - @ViewBuilder private var imageView: some View { - AsyncImage(url: study.titleImage) { image in - image - .resizable() - .scaledToFill() - .frame(height: 160) - } placeholder: { - HStack { - Spacer() - ProgressView() - Spacer() - } - } - .frame(height: 160) - .background { - Color(UIColor.secondarySystemBackground) - } - } - @ViewBuilder private var studyDescription: some View { VStack(spacing: 0) { - HStack { - VStack(alignment: .leading) { - Text(study.title) - .font(.title2) - .bold() - Text(study.organization.title) - .foregroundStyle(.secondary) - } - Spacer() - HStack(alignment: .top) { - AsyncImage(url: study.organization.logo) { image in - image - .resizable() - .scaledToFit() - } placeholder: { - ProgressView() - } - .frame(width: 50) - } - } + StudyHeader(study: study) .padding() Divider() Text(study.description) @@ -110,10 +70,11 @@ struct StudyView: View { #Preview { ZStack { - Color.gray + Color(.systemGroupedBackground) + .edgesIgnoringSafeArea(.all) StudyView(study: StudyModule().studies[0]) - .previewWith(standard: StudyApplicationStandard()) { - StudyModule() - } } + .previewWith(standard: StudyApplicationStandard()) { + StudyModule() + } } diff --git a/StudyApplication/StudyApplication.swift b/StudyApplication/StudyApplication.swift index 1d65601..ad46a2e 100644 --- a/StudyApplication/StudyApplication.swift +++ b/StudyApplication/StudyApplication.swift @@ -20,7 +20,7 @@ struct StudyApplication: App { WindowGroup { ZStack { if completedOnboardingFlow { - HomeView() + MainView() } else { EmptyView() } diff --git a/StudyApplication/StudyApplicationDelegate.swift b/StudyApplication/StudyApplicationDelegate.swift index 2feb735..45eff17 100644 --- a/StudyApplication/StudyApplicationDelegate.swift +++ b/StudyApplication/StudyApplicationDelegate.swift @@ -37,6 +37,7 @@ class StudyApplicationDelegate: SpeziAppDelegate { SchedulerStorage(for: StudyApplicationScheduler.self, mockedStorage: false) OnboardingDataSource() StudyModule() + DailyStepCountGoalModule() } } diff --git a/StudyApplication/StudyApplicationStandard.swift b/StudyApplication/StudyApplicationStandard.swift index b9dcaf2..78e845a 100644 --- a/StudyApplication/StudyApplicationStandard.swift +++ b/StudyApplication/StudyApplicationStandard.swift @@ -68,6 +68,11 @@ actor StudyApplicationStandard: Standard, EnvironmentAccessible, HealthKitConstr func add(sample: HKSample) async { + guard !FeatureFlags.disableFirebase else { + logger.debug("Firebase disabled - would upload HKSample: \(sample)") + return + } + do { try await healthKitDocument(id: sample.id).setData(from: sample.resource) } catch { @@ -76,6 +81,11 @@ actor StudyApplicationStandard: Standard, EnvironmentAccessible, HealthKitConstr } func remove(sample: HKDeletedObject) async { + guard !FeatureFlags.disableFirebase else { + logger.debug("Firebase disabled - would remove HKDeletedObject: \(sample)") + return + } + do { try await healthKitDocument(id: sample.uuid).delete() } catch { @@ -84,6 +94,11 @@ actor StudyApplicationStandard: Standard, EnvironmentAccessible, HealthKitConstr } func add(response: ModelsR4.QuestionnaireResponse) async { + guard !FeatureFlags.disableFirebase else { + logger.debug("Firebase disabled - would upload questionnaire response: \(response.description)") + return + } + let id = response.identifier?.value?.value?.string ?? UUID().uuidString do { diff --git a/StudyApplication/StudyOnboarding/Consent.swift b/StudyApplication/StudyOnboarding/Consent.swift index 235375f..6bbf6c9 100644 --- a/StudyApplication/StudyOnboarding/Consent.swift +++ b/StudyApplication/StudyOnboarding/Consent.swift @@ -28,9 +28,8 @@ struct Consent: View { } -#if DEBUG #Preview { - guard let studyConsentDocument = Study.vascTracStanford.consentDocument else { + guard let studyConsentDocument = Study.mockStudy.consentDocument else { fatalError("Could not load consent document.") } @@ -41,4 +40,3 @@ struct Consent: View { OnboardingDataSource() } } -#endif diff --git a/StudyApplication/StudyOnboarding/OnboardingMechanisms/InvitationCode/InvitationCodeView.swift b/StudyApplication/StudyOnboarding/OnboardingMechanisms/InvitationCode/InvitationCodeView.swift index b9d5987..5b08404 100644 --- a/StudyApplication/StudyOnboarding/OnboardingMechanisms/InvitationCode/InvitationCodeView.swift +++ b/StudyApplication/StudyOnboarding/OnboardingMechanisms/InvitationCode/InvitationCodeView.swift @@ -77,10 +77,12 @@ struct InvitationCodeView: View { Image(systemName: "rectangle.and.pencil.and.ellipsis") .resizable() .scaledToFit() - .frame(height: 100) + .frame(height: 80) .accessibilityHidden(true) .foregroundStyle(Color.accentColor) + .padding(.top, 24) Text("Plase enter your invitation code to join the \(study.title) study.") + .multilineTextAlignment(.center) } } @@ -101,7 +103,7 @@ struct InvitationCodeView: View { private func verifyOnboardingCode() async { do { - if FeatureFlags.disableFirebase { + if FeatureFlags.disableFirebase || ProcessInfo.processInfo.isPreviewSimulator { guard invitationCode == "VASCTRAC" else { throw InviationCodeError.invitationCodeInvalid } @@ -136,3 +138,13 @@ struct InvitationCodeView: View { } } } + + +#Preview { + OnboardingStack { + InvitationCodeView(study: StudyModule().studies[0]) + } + .previewWith(standard: StudyApplicationStandard()) { + StudyModule() + } +} diff --git a/StudyApplication/StudyOnboarding/StudyOnboardingFlow.swift b/StudyApplication/StudyOnboarding/StudyOnboardingFlow.swift index 5db5c27..bc671c5 100644 --- a/StudyApplication/StudyOnboarding/StudyOnboardingFlow.swift +++ b/StudyApplication/StudyOnboarding/StudyOnboardingFlow.swift @@ -65,3 +65,14 @@ struct StudyOnboardingFlow: View { self._studyOnboardingComplete = studyOnboardingComplete } } + + +#Preview { + StudyOnboardingFlow( + study: StudyModule().studies[0], + studyOnboardingComplete: .constant(false) + ) + .previewWith(standard: StudyApplicationStandard()) { + StudyModule() + } +} diff --git a/firebase.json b/firebase.json index 33d74e0..33eab0c 100644 --- a/firebase.json +++ b/firebase.json @@ -6,8 +6,9 @@ "rules": "firebasestorage.rules" }, "functions": { - "codebase": "callable-functions", + "codebase": "spezistudyapp", "predeploy": [ + "npm --prefix \"$RESOURCE_DIR\" install", "npm --prefix \"$RESOURCE_DIR\" run lint" ] }, diff --git a/firebase/auth_export/accounts.json b/firebase/auth_export/accounts.json new file mode 100644 index 0000000..135e08d --- /dev/null +++ b/firebase/auth_export/accounts.json @@ -0,0 +1 @@ +{"kind":"identitytoolkit#DownloadAccountResponse","users":[]} \ No newline at end of file diff --git a/firebase/auth_export/config.json b/firebase/auth_export/config.json new file mode 100644 index 0000000..6f240f7 --- /dev/null +++ b/firebase/auth_export/config.json @@ -0,0 +1 @@ +{"signIn":{"allowDuplicateEmails":false},"emailPrivacyConfig":{"enableImprovedEmailPrivacy":false}} \ No newline at end of file diff --git a/firebase/firebase-export-metadata.json b/firebase/firebase-export-metadata.json new file mode 100644 index 0000000..34dbe30 --- /dev/null +++ b/firebase/firebase-export-metadata.json @@ -0,0 +1,16 @@ +{ + "version": "13.5.1", + "firestore": { + "version": "1.19.3", + "path": "firestore_export", + "metadata_file": "firestore_export/firestore_export.overall_export_metadata" + }, + "auth": { + "version": "13.5.1", + "path": "auth_export" + }, + "storage": { + "version": "13.5.1", + "path": "storage_export" + } +} \ No newline at end of file diff --git a/firebase/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata b/firebase/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata new file mode 100644 index 0000000..d61f970 Binary files /dev/null and b/firebase/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata differ diff --git a/firebase/firestore_export/all_namespaces/all_kinds/output-0 b/firebase/firestore_export/all_namespaces/all_kinds/output-0 new file mode 100644 index 0000000..457ea48 Binary files /dev/null and b/firebase/firestore_export/all_namespaces/all_kinds/output-0 differ diff --git a/firebase/firestore_export/firestore_export.overall_export_metadata b/firebase/firestore_export/firestore_export.overall_export_metadata new file mode 100644 index 0000000..ffa6533 Binary files /dev/null and b/firebase/firestore_export/firestore_export.overall_export_metadata differ diff --git a/firebase/storage_export/buckets.json b/firebase/storage_export/buckets.json new file mode 100644 index 0000000..91e2b36 --- /dev/null +++ b/firebase/storage_export/buckets.json @@ -0,0 +1,7 @@ +{ + "buckets": [ + { + "id": "stanfordspezistudyapp.appspot.com" + } + ] +} \ No newline at end of file