diff --git a/.swiftlint.yml b/.swiftlint.yml index 782ebd7..50fbcb8 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -369,9 +369,6 @@ only_rules: # The variable should be placed on the left, the constant on the right of a comparison operator. - yoda_condition -attributes: - attributes_with_arguments_always_on_line_above: false - deployment_target: # Availability checks or attributes shouldn’t be using older versions that are satisfied by the deployment target. iOSApplicationExtension_deployment_target: 16.0 iOS_deployment_target: 16.0 diff --git a/PICS.xcodeproj/project.pbxproj b/PICS.xcodeproj/project.pbxproj index eb4a882..aea99d9 100644 --- a/PICS.xcodeproj/project.pbxproj +++ b/PICS.xcodeproj/project.pbxproj @@ -7,18 +7,14 @@ objects = { /* Begin PBXBuildFile section */ - 22201EE92B9259B800E38F47 /* PHQ-4.json in Resources */ = {isa = PBXBuildFile; fileRef = 22201EE62B9259B800E38F47 /* PHQ-4.json */; }; - 22306A2A2B7C4E8E000A8EC1 /* OnboardingQuestionnaire.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22306A292B7C4E8E000A8EC1 /* OnboardingQuestionnaire.swift */; }; + 22306A2A2B7C4E8E000A8EC1 /* PersonalInformationQuestionnaire.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22306A292B7C4E8E000A8EC1 /* PersonalInformationQuestionnaire.swift */; }; 22306A3E2B8F0097000A8EC1 /* OnboardingSurveyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22306A3D2B8F0097000A8EC1 /* OnboardingSurveyView.swift */; }; 22306A412B8F20C4000A8EC1 /* AccountQuestionnaire.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22306A402B8F20C4000A8EC1 /* AccountQuestionnaire.swift */; }; - 22306A462B8FB173000A8EC1 /* OnboardingQuestionnaireDashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22306A452B8FB173000A8EC1 /* OnboardingQuestionnaireDashboard.swift */; }; 22333FFB2B9A577F008670AE /* DeviceMotionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22333FFA2B9A577F008670AE /* DeviceMotionModel.swift */; }; 22365B782B902C9100C4528E /* Onboarding-Questionnaire.json in Resources */ = {isa = PBXBuildFile; fileRef = 22365B772B902C9100C4528E /* Onboarding-Questionnaire.json */; }; - 226C16F02B69DF3500FBA97D /* HeightKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 226C16EF2B69DF3500FBA97D /* HeightKey.swift */; }; - 226C16F22B6C820C00FBA97D /* WeightKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 226C16F12B6C820B00FBA97D /* WeightKey.swift */; }; 22C75CDA2B969B89008986AF /* ReactionTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C75CD92B969B89008986AF /* ReactionTime.swift */; }; 22C75CE12B978E33008986AF /* Medication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C75CE02B978E33008986AF /* Medication.swift */; }; - 22C75CF02B979D02008986AF /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C75CEF2B979D02008986AF /* ContentView.swift */; }; + 22C75CF02B979D02008986AF /* ImageCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C75CEF2B979D02008986AF /* ImageCanvas.swift */; }; 22C75CF72B979EA7008986AF /* ImageSource in Frameworks */ = {isa = PBXBuildFile; productRef = 22C75CF62B979EA7008986AF /* ImageSource */; }; 27FA29902A388E9B009CAC45 /* ModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FA298F2A388E9B009CAC45 /* ModalView.swift */; }; 2F1AC9DF2B4E840E00C24973 /* PICS.docc in Sources */ = {isa = PBXBuildFile; fileRef = 2F1AC9DE2B4E840E00C24973 /* PICS.docc */; }; @@ -26,7 +22,6 @@ 2F49B7762980407C00BCB272 /* Spezi in Frameworks */ = {isa = PBXBuildFile; productRef = 2F49B7752980407B00BCB272 /* Spezi */; }; 2F4E237E2989A2FE0013F3D9 /* LaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F4E237D2989A2FE0013F3D9 /* LaunchTests.swift */; }; 2F4E23832989D51F0013F3D9 /* PICSTestingSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F4E23822989D51F0013F3D9 /* PICSTestingSetup.swift */; }; - 2F4FC8D729EE69D300BFFE26 /* MockUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F4FC8D629EE69D300BFFE26 /* MockUpload.swift */; }; 2F5E32BD297E05EA003432F8 /* PICSDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F5E32BC297E05EA003432F8 /* PICSDelegate.swift */; }; 2F6025CB29BBE70F0045459E /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2F6025CA29BBE70F0045459E /* GoogleService-Info.plist */; }; 2FA0BFED2ACC977500E0EF83 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */; }; @@ -35,7 +30,6 @@ 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 */; }; - 2FC3439029EE6346002D773C /* SocialSupportQuestionnaire.json in Resources */ = {isa = PBXBuildFile; fileRef = 2FE5DC5529EDD811004B9AB4 /* SocialSupportQuestionnaire.json */; }; 2FC3439229EE634B002D773C /* ConsentDocument.md in Resources */ = {isa = PBXBuildFile; fileRef = 2FE5DC2C29EDD78E004B9AB4 /* ConsentDocument.md */; }; 2FC975A82978F11A00BA99FE /* Home.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC975A72978F11A00BA99FE /* Home.swift */; }; 2FE5DC2629EDD38A004B9AB4 /* Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DC2529EDD38A004B9AB4 /* Contacts.swift */; }; @@ -67,7 +61,6 @@ 2FE5DC8F29EDD980004B9AB4 /* SpeziViews in Frameworks */ = {isa = PBXBuildFile; productRef = 2FE5DC8E29EDD980004B9AB4 /* SpeziViews */; }; 2FE5DC9929EDD9D9004B9AB4 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 2FE5DC9829EDD9D9004B9AB4 /* XCTestExtensions */; }; 2FE5DCB129EE6107004B9AB4 /* AccountOnboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DCAC29EE6107004B9AB4 /* AccountOnboarding.swift */; }; - 2FF53D8B2A8725DE00042B76 /* SpeziMockWebService in Frameworks */ = {isa = PBXBuildFile; productRef = 2FF53D8A2A8725DE00042B76 /* SpeziMockWebService */; }; 2FF53D8D2A8729D600042B76 /* PICSStandard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF53D8C2A8729D600042B76 /* PICSStandard.swift */; }; 5661551D2AB8384200209B80 /* SwiftPackageList in Frameworks */ = {isa = PBXBuildFile; productRef = 5661551C2AB8384200209B80 /* SwiftPackageList */; }; 566155292AB8447C00209B80 /* Package+LicenseType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 566155282AB8447C00209B80 /* Package+LicenseType.swift */; }; @@ -77,18 +70,14 @@ 653A2551283387FE005D4D48 /* PICS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653A2550283387FE005D4D48 /* PICS.swift */; }; 653A255528338800005D4D48 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 653A255428338800005D4D48 /* Assets.xcassets */; }; 653A256228338800005D4D48 /* PICSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653A256128338800005D4D48 /* PICSTests.swift */; }; - 741110F22B5767DF00DFC79E /* PROM-Questionnaire.json in Resources */ = {isa = PBXBuildFile; fileRef = 741110F12B5767DE00DFC79E /* PROM-Questionnaire.json */; }; - 748117182B74BC3E00801A9A /* EQ5D5L.json.license in Resources */ = {isa = PBXBuildFile; fileRef = 748117162B74BC3E00801A9A /* EQ5D5L.json.license */; }; - 7481171A2B74BC5B00801A9A /* Self-MNA.json in Resources */ = {isa = PBXBuildFile; fileRef = 748117192B74BC5B00801A9A /* Self-MNA.json */; }; 74AE05882B918DAF00AB5287 /* StroopTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74AE05872B918DAF00AB5287 /* StroopTest.swift */; }; 74AE05932B98620300AB5287 /* AssessmentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74AE05922B98620300AB5287 /* AssessmentsTests.swift */; }; 74AE05A92B9BB47F00AB5287 /* XCTSpezi in Frameworks */ = {isa = PBXBuildFile; productRef = 74AE05A82B9BB47F00AB5287 /* XCTSpezi */; }; 74AE05AD2BA1241D00AB5287 /* SchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74AE05AC2BA1241D00AB5287 /* SchedulerTests.swift */; }; - 74AE05B22BA2A44F00AB5287 /* EQ5D5L.json in Resources */ = {isa = PBXBuildFile; fileRef = 74AE05B12BA2A44F00AB5287 /* EQ5D5L.json */; }; 74C593952B720781002D0274 /* (null) in Resources */ = {isa = PBXBuildFile; }; 74C593972B720B5E002D0274 /* (null) in Resources */ = {isa = PBXBuildFile; }; 74C5939A2B720F1B002D0274 /* (null) in Resources */ = {isa = PBXBuildFile; }; - 8607505A2BA1278200CABE92 /* ApptInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 860750592BA1278200CABE92 /* ApptInfoTests.swift */; }; + 8607505A2BA1278200CABE92 /* PatientInformationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 860750592BA1278200CABE92 /* PatientInformationTests.swift */; }; 862AB7F62B9AA6460048D158 /* ContactsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 862AB7F52B9AA6460048D158 /* ContactsTests.swift */; }; 86400D192B7C4B6D009FEC10 /* ApptInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86400D182B7C4B6D009FEC10 /* ApptInfo.swift */; }; 8644E6712B6C7243001218D0 /* AppointmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8644E6702B6C7243001218D0 /* AppointmentView.swift */; }; @@ -100,7 +89,7 @@ 8649094A2B8AB65600054C9A /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 864909492B8AB65600054C9A /* TimelineView.swift */; }; 86EB7FF72B8FB7A000D52EE2 /* NotificationPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86EB7FF62B8FB7A000D52EE2 /* NotificationPermissions.swift */; }; 86EF04BE2B9B1661005596D8 /* AppointmentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86EF04BD2B9B1661005596D8 /* AppointmentsTests.swift */; }; - 86F62AF62B916B670075F23C /* AppointmentInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86F62AF52B916B670075F23C /* AppointmentInformation.swift */; }; + 86F62AF62B916B670075F23C /* PatientInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86F62AF52B916B670075F23C /* PatientInformation.swift */; }; 9733CFC62A8066DE001B7ABC /* SpeziOnboarding in Frameworks */ = {isa = PBXBuildFile; productRef = 2FE5DC8029EDD91D004B9AB4 /* SpeziOnboarding */; }; 9739A0C62AD7B5730084BEA5 /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 9739A0C52AD7B5730084BEA5 /* FirebaseStorage */; }; 97D73D6A2AD860AD00B47FA0 /* SpeziFirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 97D73D692AD860AD00B47FA0 /* SpeziFirebaseStorage */; }; @@ -114,8 +103,17 @@ A480C7C02B6D5A3700B29A07 /* HKVisualization.swift in Sources */ = {isa = PBXBuildFile; fileRef = A480C7BF2B6D5A3700B29A07 /* HKVisualization.swift */; }; A480C7C42B6D721A00B29A07 /* HKVisualizationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A480C7C32B6D721A00B29A07 /* HKVisualizationItem.swift */; }; A9720E432ABB68CC00872D23 /* AccountSetupHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9720E422ABB68CC00872D23 /* AccountSetupHeader.swift */; }; + A9A48A932BA90011002CB862 /* PHQ-4.json in Resources */ = {isa = PBXBuildFile; fileRef = A9A48A902BA90010002CB862 /* PHQ-4.json */; }; + A9A48A942BA90011002CB862 /* Self-MNA.json in Resources */ = {isa = PBXBuildFile; fileRef = A9A48A912BA90010002CB862 /* Self-MNA.json */; }; + A9A48A952BA90011002CB862 /* EQ5D5L.json in Resources */ = {isa = PBXBuildFile; fileRef = A9A48A922BA90010002CB862 /* EQ5D5L.json */; }; + A9A48A9B2BA90783002CB862 /* AssessmentResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A48A9A2BA90783002CB862 /* AssessmentResult.swift */; }; + A9A48A9D2BA907E6002CB862 /* AssessmentResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A48A9C2BA907E6002CB862 /* AssessmentResults.swift */; }; + A9A48AA92BA912B4002CB862 /* AssessmentTaskSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A48AA82BA912B4002CB862 /* AssessmentTaskSection.swift */; }; + A9A48AAC2BA912E0002CB862 /* AssessmentTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A48AAB2BA912E0002CB862 /* AssessmentTask.swift */; }; + A9A48AB02BA916D2002CB862 /* AssessmentNotCompletedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A48AAF2BA916D2002CB862 /* AssessmentNotCompletedView.swift */; }; A9D83F962B083794000D0C78 /* SpeziFirebaseAccountStorage in Frameworks */ = {isa = PBXBuildFile; productRef = A9D83F952B083794000D0C78 /* SpeziFirebaseAccountStorage */; }; A9DFE8A92ABE551400428242 /* AccountButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DFE8A82ABE551400428242 /* AccountButton.swift */; }; + A9F1F5FC2BA953F600A8E6DB /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = A9F1F5FB2BA953F600A8E6DB /* OrderedCollections */; }; A9FE7AD02AA39BAB0077B045 /* AccountSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FE7ACF2AA39BAB0077B045 /* AccountSheet.swift */; }; /* End PBXBuildFile section */ @@ -137,24 +135,18 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 22201EE62B9259B800E38F47 /* PHQ-4.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "PHQ-4.json"; sourceTree = ""; }; - 22201EE72B9259B800E38F47 /* Self-MNA.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "Self-MNA.json"; sourceTree = ""; }; - 22306A292B7C4E8E000A8EC1 /* OnboardingQuestionnaire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingQuestionnaire.swift; sourceTree = ""; }; + 22306A292B7C4E8E000A8EC1 /* PersonalInformationQuestionnaire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalInformationQuestionnaire.swift; sourceTree = ""; }; 22306A3D2B8F0097000A8EC1 /* OnboardingSurveyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingSurveyView.swift; sourceTree = ""; }; 22306A402B8F20C4000A8EC1 /* AccountQuestionnaire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountQuestionnaire.swift; sourceTree = ""; }; - 22306A452B8FB173000A8EC1 /* OnboardingQuestionnaireDashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingQuestionnaireDashboard.swift; sourceTree = ""; }; 22333FFA2B9A577F008670AE /* DeviceMotionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceMotionModel.swift; sourceTree = ""; }; 22365B772B902C9100C4528E /* Onboarding-Questionnaire.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "Onboarding-Questionnaire.json"; sourceTree = ""; }; - 226C16EF2B69DF3500FBA97D /* HeightKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeightKey.swift; sourceTree = ""; }; - 226C16F12B6C820B00FBA97D /* WeightKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeightKey.swift; sourceTree = ""; }; 22C75CD92B969B89008986AF /* ReactionTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionTime.swift; sourceTree = ""; }; 22C75CE02B978E33008986AF /* Medication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Medication.swift; sourceTree = ""; }; - 22C75CEF2B979D02008986AF /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 22C75CEF2B979D02008986AF /* ImageCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCanvas.swift; sourceTree = ""; }; 27FA298F2A388E9B009CAC45 /* ModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalView.swift; sourceTree = ""; }; 2F1AC9DE2B4E840E00C24973 /* PICS.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = PICS.docc; sourceTree = ""; }; 2F4E237D2989A2FE0013F3D9 /* LaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchTests.swift; sourceTree = ""; }; 2F4E23822989D51F0013F3D9 /* PICSTestingSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PICSTestingSetup.swift; sourceTree = ""; }; - 2F4FC8D629EE69D300BFFE26 /* MockUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUpload.swift; sourceTree = ""; }; 2F5E32BC297E05EA003432F8 /* PICSDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PICSDelegate.swift; sourceTree = ""; }; 2F6025CA29BBE70F0045459E /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; @@ -179,7 +171,6 @@ 2FE5DC4B29EDD7FA004B9AB4 /* PICSTaskContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PICSTaskContext.swift; sourceTree = ""; }; 2FE5DC4C29EDD7FA004B9AB4 /* PICSScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PICSScheduler.swift; sourceTree = ""; }; 2FE5DC4D29EDD7FA004B9AB4 /* Bundle+Questionnaire.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Bundle+Questionnaire.swift"; sourceTree = ""; }; - 2FE5DC5529EDD811004B9AB4 /* SocialSupportQuestionnaire.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SocialSupportQuestionnaire.json; sourceTree = ""; }; 2FE5DCAC29EE6107004B9AB4 /* AccountOnboarding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountOnboarding.swift; sourceTree = ""; }; 2FF53D8C2A8729D600042B76 /* PICSStandard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PICSStandard.swift; sourceTree = ""; }; 566155282AB8447C00209B80 /* Package+LicenseType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Package+LicenseType.swift"; sourceTree = ""; }; @@ -193,15 +184,10 @@ 653A256128338800005D4D48 /* PICSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PICSTests.swift; sourceTree = ""; }; 653A256728338800005D4D48 /* PICSUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PICSUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 653A258928339462005D4D48 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 741110F12B5767DE00DFC79E /* PROM-Questionnaire.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "PROM-Questionnaire.json"; sourceTree = ""; }; - 748117162B74BC3E00801A9A /* EQ5D5L.json.license */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = EQ5D5L.json.license; sourceTree = ""; }; - 748117192B74BC5B00801A9A /* Self-MNA.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "Self-MNA.json"; sourceTree = ""; }; - 7481171B2B74BC6D00801A9A /* PHQ-4.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "PHQ-4.json"; sourceTree = ""; }; 74AE05872B918DAF00AB5287 /* StroopTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StroopTest.swift; sourceTree = ""; }; 74AE05922B98620300AB5287 /* AssessmentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssessmentsTests.swift; sourceTree = ""; }; 74AE05AC2BA1241D00AB5287 /* SchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchedulerTests.swift; sourceTree = ""; }; - 74AE05B12BA2A44F00AB5287 /* EQ5D5L.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = EQ5D5L.json; sourceTree = ""; }; - 860750592BA1278200CABE92 /* ApptInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApptInfoTests.swift; sourceTree = ""; }; + 860750592BA1278200CABE92 /* PatientInformationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatientInformationTests.swift; sourceTree = ""; }; 862AB7F52B9AA6460048D158 /* ContactsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsTests.swift; sourceTree = ""; }; 86400D182B7C4B6D009FEC10 /* ApptInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApptInfo.swift; sourceTree = ""; }; 8644E6702B6C7243001218D0 /* AppointmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppointmentView.swift; sourceTree = ""; }; @@ -213,7 +199,7 @@ 864909492B8AB65600054C9A /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = ""; }; 86EB7FF62B8FB7A000D52EE2 /* NotificationPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissions.swift; sourceTree = ""; }; 86EF04BD2B9B1661005596D8 /* AppointmentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppointmentsTests.swift; sourceTree = ""; }; - 86F62AF52B916B670075F23C /* AppointmentInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppointmentInformation.swift; sourceTree = ""; }; + 86F62AF52B916B670075F23C /* PatientInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatientInformation.swift; sourceTree = ""; }; A403A52D2B705A8C003CFA5C /* HealthVisualizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthVisualizationTests.swift; sourceTree = ""; }; A40419F92B9F95EC0038D791 /* OnboardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTests.swift; sourceTree = ""; }; A40559A32B98204C00221783 /* HKVizUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKVizUnitTests.swift; sourceTree = ""; }; @@ -223,6 +209,14 @@ A480C7BF2B6D5A3700B29A07 /* HKVisualization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKVisualization.swift; sourceTree = ""; }; A480C7C32B6D721A00B29A07 /* HKVisualizationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKVisualizationItem.swift; sourceTree = ""; }; A9720E422ABB68CC00872D23 /* AccountSetupHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSetupHeader.swift; sourceTree = ""; }; + A9A48A902BA90010002CB862 /* PHQ-4.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "PHQ-4.json"; sourceTree = ""; }; + A9A48A912BA90010002CB862 /* Self-MNA.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "Self-MNA.json"; sourceTree = ""; }; + A9A48A922BA90010002CB862 /* EQ5D5L.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = EQ5D5L.json; sourceTree = ""; }; + A9A48A9A2BA90783002CB862 /* AssessmentResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssessmentResult.swift; sourceTree = ""; }; + A9A48A9C2BA907E6002CB862 /* AssessmentResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssessmentResults.swift; sourceTree = ""; }; + A9A48AA82BA912B4002CB862 /* AssessmentTaskSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssessmentTaskSection.swift; sourceTree = ""; }; + A9A48AAB2BA912E0002CB862 /* AssessmentTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssessmentTask.swift; sourceTree = ""; }; + A9A48AAF2BA916D2002CB862 /* AssessmentNotCompletedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssessmentNotCompletedView.swift; sourceTree = ""; }; A9DFE8A82ABE551400428242 /* AccountButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountButton.swift; sourceTree = ""; }; A9FE7ACF2AA39BAB0077B045 /* AccountSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSheet.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -242,6 +236,7 @@ 74AE05A92B9BB47F00AB5287 /* XCTSpezi in Frameworks */, 22C75CF72B979EA7008986AF /* ImageSource in Frameworks */, 5661551D2AB8384200209B80 /* SwiftPackageList in Frameworks */, + A9F1F5FC2BA953F600A8E6DB /* OrderedCollections in Frameworks */, 2FB099B12A875DF100B20952 /* FirebaseFirestore in Frameworks */, A9D83F962B083794000D0C78 /* SpeziFirebaseAccountStorage in Frameworks */, 2FB099B62A875E2B00B20952 /* HealthKitOnFHIR in Frameworks */, @@ -249,7 +244,6 @@ 2FE5DC8C29EDD972004B9AB4 /* SpeziSecureStorage in Frameworks */, 2FE5DC7529EDD8E6004B9AB4 /* SpeziFirebaseAccount in Frameworks */, 9739A0C62AD7B5730084BEA5 /* FirebaseStorage in Frameworks */, - 2FF53D8B2A8725DE00042B76 /* SpeziMockWebService in Frameworks */, 2FE5DC7229EDD8D3004B9AB4 /* SpeziHealthKit in Frameworks */, 2F49B7762980407C00BCB272 /* Spezi in Frameworks */, 2FE5DC8F29EDD980004B9AB4 /* SpeziViews in Frameworks */, @@ -279,23 +273,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 226C16EC2B69DEC600FBA97D /* Patient Information */ = { - isa = PBXGroup; - children = ( - 226C16EF2B69DF3500FBA97D /* HeightKey.swift */, - 226C16F12B6C820B00FBA97D /* WeightKey.swift */, - ); - path = "Patient Information"; - sourceTree = ""; - }; - 2F4FC8D529EE69BE00BFFE26 /* MockUpload */ = { - isa = PBXGroup; - children = ( - 2F4FC8D629EE69D300BFFE26 /* MockUpload.swift */, - ); - path = MockUpload; - sourceTree = ""; - }; 2FC9759D2978E30800BA99FE /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -318,18 +295,14 @@ 2FE5DC2829EDD398004B9AB4 /* Onboarding */ = { isa = PBXGroup; children = ( - 2FE5DC3129EDD7CA004B9AB4 /* OnboardingFlow.swift */, - 86EB7FF62B8FB7A000D52EE2 /* NotificationPermissions.swift */, - 2FE5DC3429EDD7CA004B9AB4 /* Welcome.swift */, - 86400D182B7C4B6D009FEC10 /* ApptInfo.swift */, - 2FE5DC3229EDD7CA004B9AB4 /* InterestingModules.swift */, + A9C355042BA9342900424179 /* Information */, + A9C355012BA9341A00424179 /* Medication */, + A9C355072BA9343900424179 /* Permissions */, + 22306A402B8F20C4000A8EC1 /* AccountQuestionnaire.swift */, 2FE5DCAC29EE6107004B9AB4 /* AccountOnboarding.swift */, + 86400D182B7C4B6D009FEC10 /* ApptInfo.swift */, 2FE5DC2F29EDD7CA004B9AB4 /* Consent.swift */, - 2FE5DC3029EDD7CA004B9AB4 /* HealthKitPermissions.swift */, - 22C75CE02B978E33008986AF /* Medication.swift */, - 22C75CEF2B979D02008986AF /* ContentView.swift */, - 22306A292B7C4E8E000A8EC1 /* OnboardingQuestionnaire.swift */, - 22306A402B8F20C4000A8EC1 /* AccountQuestionnaire.swift */, + 2FE5DC3129EDD7CA004B9AB4 /* OnboardingFlow.swift */, ); path = Onboarding; sourceTree = ""; @@ -337,18 +310,13 @@ 2FE5DC2D29EDD792004B9AB4 /* Resources */ = { isa = PBXGroup; children = ( - 74AE05B12BA2A44F00AB5287 /* EQ5D5L.json */, - 7481171B2B74BC6D00801A9A /* PHQ-4.json */, - 748117192B74BC5B00801A9A /* Self-MNA.json */, - 748117162B74BC3E00801A9A /* EQ5D5L.json.license */, - 22201EE62B9259B800E38F47 /* PHQ-4.json */, - 22201EE72B9259B800E38F47 /* Self-MNA.json */, + A9A48A922BA90010002CB862 /* EQ5D5L.json */, + A9A48A902BA90010002CB862 /* PHQ-4.json */, + A9A48A912BA90010002CB862 /* Self-MNA.json */, 22365B772B902C9100C4528E /* Onboarding-Questionnaire.json */, - 741110F12B5767DE00DFC79E /* PROM-Questionnaire.json */, 653A255428338800005D4D48 /* Assets.xcassets */, 2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */, 2FE5DC2C29EDD78E004B9AB4 /* ConsentDocument.md */, - 2FE5DC5529EDD811004B9AB4 /* SocialSupportQuestionnaire.json */, ); path = Resources; sourceTree = ""; @@ -356,15 +324,15 @@ 2FE5DC3B29EDD7D0004B9AB4 /* Schedule */ = { isa = PBXGroup; children = ( + A9C354FC2BA9330900424179 /* PatientInformation */, 2FE5DC4D29EDD7FA004B9AB4 /* Bundle+Questionnaire.swift */, 2FE5DC4929EDD7FA004B9AB4 /* EventContext.swift */, 2FE5DC4A29EDD7FA004B9AB4 /* EventContextView.swift */, - 2FE5DC4829EDD7FA004B9AB4 /* ScheduleView.swift */, - 2FE5DC4C29EDD7FA004B9AB4 /* PICSScheduler.swift */, - 2FE5DC4B29EDD7FA004B9AB4 /* PICSTaskContext.swift */, 27FA298F2A388E9B009CAC45 /* ModalView.swift */, 22306A3D2B8F0097000A8EC1 /* OnboardingSurveyView.swift */, - 22306A452B8FB173000A8EC1 /* OnboardingQuestionnaireDashboard.swift */, + 2FE5DC4C29EDD7FA004B9AB4 /* PICSScheduler.swift */, + 2FE5DC4B29EDD7FA004B9AB4 /* PICSTaskContext.swift */, + 2FE5DC4829EDD7FA004B9AB4 /* ScheduleView.swift */, ); path = Schedule; sourceTree = ""; @@ -424,7 +392,6 @@ 653A254F283387FE005D4D48 /* PICS */ = { isa = PBXGroup; children = ( - 226C16EC2B69DEC600FBA97D /* Patient Information */, 653A2550283387FE005D4D48 /* PICS.swift */, 2F5E32BC297E05EA003432F8 /* PICSDelegate.swift */, 2FF53D8C2A8729D600042B76 /* PICSStandard.swift */, @@ -438,7 +405,6 @@ 2FE5DC2729EDD38D004B9AB4 /* Contacts */, 56F6F29E2AB441640022FE5A /* Contributions */, A480C7BE2B6D5A0B00B29A07 /* HealthVisulization */, - 2F4FC8D529EE69BE00BFFE26 /* MockUpload */, 2FE5DC3C29EDD7DA004B9AB4 /* SharedContext */, 2FE5DC3D29EDD7E4004B9AB4 /* Helper */, 2FE5DC2D29EDD792004B9AB4 /* Resources */, @@ -453,7 +419,7 @@ 653A256128338800005D4D48 /* PICSTests.swift */, A40559A32B98204C00221783 /* HKVizUnitTests.swift */, A45546C12B9E56A1006DB4B4 /* AssessmentsUnitTests.swift */, - 860750592BA1278200CABE92 /* ApptInfoTests.swift */, + 860750592BA1278200CABE92 /* PatientInformationTests.swift */, ); path = PICSTests; sourceTree = ""; @@ -483,10 +449,9 @@ isa = PBXGroup; children = ( 8644E6702B6C7243001218D0 /* AppointmentView.swift */, - 8644E69A2B722708001218D0 /* Appointments.swift */, - 86F62AF52B916B670075F23C /* AppointmentInformation.swift */, 8644E6862B7204A7001218D0 /* AppointmentBlock.swift */, 8644E6832B72046B001218D0 /* GettingThere.swift */, + 8644E69A2B722708001218D0 /* Appointments.swift */, 8644E67A2B6C75B1001218D0 /* MapView.swift */, 864909492B8AB65600054C9A /* TimelineView.swift */, ); @@ -496,12 +461,10 @@ A45993272B90541600A98C95 /* Assessment */ = { isa = PBXGroup; children = ( + A9A48A982BA90774002CB862 /* Model */, + A9A48A9E2BA911A1002CB862 /* Tests */, + A9A48AA62BA912A1002CB862 /* Views */, A45993282B90544300A98C95 /* Assessments.swift */, - 864909412B8AA1C300054C9A /* TrailMakingTest.swift */, - A459932B2B906C3B00A98C95 /* ResultsViz.swift */, - 74AE05872B918DAF00AB5287 /* StroopTest.swift */, - 22C75CD92B969B89008986AF /* ReactionTime.swift */, - 22333FFA2B9A577F008670AE /* DeviceMotionModel.swift */, ); path = Assessment; sourceTree = ""; @@ -525,6 +488,73 @@ path = Account; sourceTree = ""; }; + A9A48A982BA90774002CB862 /* Model */ = { + isa = PBXGroup; + children = ( + A9A48A9A2BA90783002CB862 /* AssessmentResult.swift */, + A9A48A9C2BA907E6002CB862 /* AssessmentResults.swift */, + 22333FFA2B9A577F008670AE /* DeviceMotionModel.swift */, + A9A48AAB2BA912E0002CB862 /* AssessmentTask.swift */, + ); + path = Model; + sourceTree = ""; + }; + A9A48A9E2BA911A1002CB862 /* Tests */ = { + isa = PBXGroup; + children = ( + 22C75CD92B969B89008986AF /* ReactionTime.swift */, + 74AE05872B918DAF00AB5287 /* StroopTest.swift */, + 864909412B8AA1C300054C9A /* TrailMakingTest.swift */, + ); + path = Tests; + sourceTree = ""; + }; + A9A48AA62BA912A1002CB862 /* Views */ = { + isa = PBXGroup; + children = ( + A9A48AA82BA912B4002CB862 /* AssessmentTaskSection.swift */, + A9A48AAF2BA916D2002CB862 /* AssessmentNotCompletedView.swift */, + A459932B2B906C3B00A98C95 /* ResultsViz.swift */, + ); + path = Views; + sourceTree = ""; + }; + A9C354FC2BA9330900424179 /* PatientInformation */ = { + isa = PBXGroup; + children = ( + 86F62AF52B916B670075F23C /* PatientInformation.swift */, + 22306A292B7C4E8E000A8EC1 /* PersonalInformationQuestionnaire.swift */, + ); + path = PatientInformation; + sourceTree = ""; + }; + A9C355012BA9341A00424179 /* Medication */ = { + isa = PBXGroup; + children = ( + 22C75CE02B978E33008986AF /* Medication.swift */, + 22C75CEF2B979D02008986AF /* ImageCanvas.swift */, + ); + path = Medication; + sourceTree = ""; + }; + A9C355042BA9342900424179 /* Information */ = { + isa = PBXGroup; + children = ( + 2FE5DC3429EDD7CA004B9AB4 /* Welcome.swift */, + 2FE5DC3229EDD7CA004B9AB4 /* InterestingModules.swift */, + ); + path = Information; + sourceTree = ""; + }; + A9C355072BA9343900424179 /* Permissions */ = { + isa = PBXGroup; + children = ( + 2FE5DC3029EDD7CA004B9AB4 /* HealthKitPermissions.swift */, + 86EB7FF62B8FB7A000D52EE2 /* NotificationPermissions.swift */, + ); + path = Permissions; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -540,7 +570,7 @@ buildRules = ( ); dependencies = ( - 566155222AB83CF200209B80 /* PBXTargetDependency */, + A9F1F5F62BA94EAD00A8E6DB /* PBXTargetDependency */, ); name = PICS; packageProductDependencies = ( @@ -558,7 +588,6 @@ 2FBD738B2A3BD150004228E7 /* SpeziScheduler */, 2F3D4ABB2A4E7C290068FB2F /* SpeziScheduler */, 2FE5DC8029EDD91D004B9AB4 /* SpeziOnboarding */, - 2FF53D8A2A8725DE00042B76 /* SpeziMockWebService */, 2FB099AE2A875DF100B20952 /* FirebaseAuth */, 2FB099B02A875DF100B20952 /* FirebaseFirestore */, 2FB099B22A875DF100B20952 /* FirebaseFirestoreSwift */, @@ -569,6 +598,7 @@ A9D83F952B083794000D0C78 /* SpeziFirebaseAccountStorage */, 22C75CF62B979EA7008986AF /* ImageSource */, 74AE05A82B9BB47F00AB5287 /* XCTSpezi */, + A9F1F5FB2BA953F600A8E6DB /* OrderedCollections */, ); productName = PICS; productReference = 653A254D283387FE005D4D48 /* PICS.app */; @@ -662,10 +692,10 @@ 2FE5DC9A29EDD9EF004B9AB4 /* XCRemoteSwiftPackageReference "XCTHealthKit" */, 2F3D4ABA2A4E7C290068FB2F /* XCRemoteSwiftPackageReference "SpeziScheduler" */, 97F466E62A76BBEE005DC9B4 /* XCRemoteSwiftPackageReference "SpeziOnboarding" */, - 2FE750CA2A87240100723EAE /* XCRemoteSwiftPackageReference "SpeziMockWebService" */, 2FB099B42A875E2B00B20952 /* XCRemoteSwiftPackageReference "HealthKitOnFHIR" */, 5661551B2AB8384200209B80 /* XCRemoteSwiftPackageReference "swift-package-list" */, 22C75CF52B979EA7008986AF /* XCRemoteSwiftPackageReference "ImageSource" */, + A9F1F5FA2BA953F600A8E6DB /* XCRemoteSwiftPackageReference "swift-collections" */, ); productRefGroup = 653A254E283387FE005D4D48 /* Products */; projectDirPath = ""; @@ -683,20 +713,17 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 7481171A2B74BC5B00801A9A /* Self-MNA.json in Resources */, 2FC3439229EE634B002D773C /* ConsentDocument.md in Resources */, 653A255528338800005D4D48 /* Assets.xcassets in Resources */, + A9A48A942BA90011002CB862 /* Self-MNA.json in Resources */, 74C5939A2B720F1B002D0274 /* (null) in Resources */, - 2FC3439029EE6346002D773C /* SocialSupportQuestionnaire.json in Resources */, - 74AE05B22BA2A44F00AB5287 /* EQ5D5L.json in Resources */, - 748117182B74BC3E00801A9A /* EQ5D5L.json.license in Resources */, + A9A48A932BA90011002CB862 /* PHQ-4.json in Resources */, 74C593952B720781002D0274 /* (null) in Resources */, 2FA0BFED2ACC977500E0EF83 /* Localizable.xcstrings in Resources */, - 741110F22B5767DF00DFC79E /* PROM-Questionnaire.json in Resources */, 74C593972B720B5E002D0274 /* (null) in Resources */, - 22201EE92B9259B800E38F47 /* PHQ-4.json in Resources */, 22365B782B902C9100C4528E /* Onboarding-Questionnaire.json in Resources */, 2F6025CB29BBE70F0045459E /* GoogleService-Info.plist in Resources */, + A9A48A952BA90011002CB862 /* EQ5D5L.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -745,12 +772,13 @@ 2FE5DC4129EDD7EE004B9AB4 /* StorageKeys.swift in Sources */, 2FE5DCB129EE6107004B9AB4 /* AccountOnboarding.swift in Sources */, 86400D192B7C4B6D009FEC10 /* ApptInfo.swift in Sources */, - 2F4FC8D729EE69D300BFFE26 /* MockUpload.swift in Sources */, 2FE5DC3A29EDD7CA004B9AB4 /* Welcome.swift in Sources */, - 22306A2A2B7C4E8E000A8EC1 /* OnboardingQuestionnaire.swift in Sources */, + 22306A2A2B7C4E8E000A8EC1 /* PersonalInformationQuestionnaire.swift in Sources */, + A9A48AB02BA916D2002CB862 /* AssessmentNotCompletedView.swift in Sources */, 2FE5DC3829EDD7CA004B9AB4 /* InterestingModules.swift in Sources */, 2FE5DC3529EDD7CA004B9AB4 /* Consent.swift in Sources */, - 22C75CF02B979D02008986AF /* ContentView.swift in Sources */, + A9A48A9D2BA907E6002CB862 /* AssessmentResults.swift in Sources */, + 22C75CF02B979D02008986AF /* ImageCanvas.swift in Sources */, 2FE5DC4529EDD7F2004B9AB4 /* Binding+Negate.swift in Sources */, 8644E67B2B6C75B1001218D0 /* MapView.swift in Sources */, 2FC975A82978F11A00BA99FE /* Home.swift in Sources */, @@ -760,9 +788,9 @@ 2F1AC9DF2B4E840E00C24973 /* PICS.docc in Sources */, 86EB7FF72B8FB7A000D52EE2 /* NotificationPermissions.swift in Sources */, 22C75CE12B978E33008986AF /* Medication.swift in Sources */, - 226C16F22B6C820C00FBA97D /* WeightKey.swift in Sources */, A45993292B90544300A98C95 /* Assessments.swift in Sources */, 2FF53D8D2A8729D600042B76 /* PICSStandard.swift in Sources */, + A9A48A9B2BA90783002CB862 /* AssessmentResult.swift in Sources */, 2FE5DC4729EDD7F2004B9AB4 /* CodableArray+RawRepresentable.swift in Sources */, 8644E6712B6C7243001218D0 /* AppointmentView.swift in Sources */, A9720E432ABB68CC00872D23 /* AccountSetupHeader.swift in Sources */, @@ -772,7 +800,6 @@ 8644E6842B72046B001218D0 /* GettingThere.swift in Sources */, 2FE5DC4F29EDD7FA004B9AB4 /* EventContext.swift in Sources */, 22333FFB2B9A577F008670AE /* DeviceMotionModel.swift in Sources */, - 226C16F02B69DF3500FBA97D /* HeightKey.swift in Sources */, 864909422B8AA1C300054C9A /* TrailMakingTest.swift in Sources */, 2FE5DC5029EDD7FA004B9AB4 /* EventContextView.swift in Sources */, 2F4E23832989D51F0013F3D9 /* PICSTestingSetup.swift in Sources */, @@ -790,15 +817,16 @@ 22C75CDA2B969B89008986AF /* ReactionTime.swift in Sources */, 2FE5DC5229EDD7FA004B9AB4 /* PICSScheduler.swift in Sources */, A9FE7AD02AA39BAB0077B045 /* AccountSheet.swift in Sources */, - 22306A462B8FB173000A8EC1 /* OnboardingQuestionnaireDashboard.swift in Sources */, 653A2551283387FE005D4D48 /* PICS.swift in Sources */, 2FE5DC3629EDD7CA004B9AB4 /* HealthKitPermissions.swift in Sources */, 22306A3E2B8F0097000A8EC1 /* OnboardingSurveyView.swift in Sources */, 8644E6872B7204A7001218D0 /* AppointmentBlock.swift in Sources */, - 86F62AF62B916B670075F23C /* AppointmentInformation.swift in Sources */, + 86F62AF62B916B670075F23C /* PatientInformation.swift in Sources */, 5661552E2AB854C000209B80 /* PackageHelper.swift in Sources */, + A9A48AA92BA912B4002CB862 /* AssessmentTaskSection.swift in Sources */, 27FA29902A388E9B009CAC45 /* ModalView.swift in Sources */, 8649094A2B8AB65600054C9A /* TimelineView.swift in Sources */, + A9A48AAC2BA912E0002CB862 /* AssessmentTask.swift in Sources */, 2FE5DC2629EDD38A004B9AB4 /* Contacts.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -809,7 +837,7 @@ files = ( A40559A42B98204C00221783 /* HKVizUnitTests.swift in Sources */, A45546C22B9E56A1006DB4B4 /* AssessmentsUnitTests.swift in Sources */, - 8607505A2BA1278200CABE92 /* ApptInfoTests.swift in Sources */, + 8607505A2BA1278200CABE92 /* PatientInformationTests.swift in Sources */, 653A256228338800005D4D48 /* PICSTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -831,10 +859,6 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 566155222AB83CF200209B80 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - productRef = 566155212AB83CF200209B80 /* SwiftPackageListJSONPlugin */; - }; 653A255F28338800005D4D48 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 653A254C283387FE005D4D48 /* PICS */; @@ -845,6 +869,10 @@ target = 653A254C283387FE005D4D48 /* PICS */; targetProxy = 653A256828338800005D4D48 /* PBXContainerItemProxy */; }; + A9F1F5F62BA94EAD00A8E6DB /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = A9F1F5F52BA94EAD00A8E6DB /* SwiftPackageListJSONPlugin */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -926,6 +954,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "PICS/Supporting Files/Info.plist"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; INFOPLIST_KEY_NSCameraUsageDescription = "This message should never appear. Please adjust this when you start using camera information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect."; INFOPLIST_KEY_NSHealthShareUsageDescription = "The PICS uses the step count to demonstrate Spezi's integration with HealthKit."; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "The PICS uses the step count to demonstrate Spezi's integration with HealthKit."; @@ -1132,6 +1161,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "PICS/Supporting Files/Info.plist"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; INFOPLIST_KEY_NSCameraUsageDescription = "This message should never appear. Please adjust this when you start using camera information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect."; INFOPLIST_KEY_NSHealthShareUsageDescription = "The PICS uses the step count to demonstrate Spezi's integration with HealthKit."; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "The PICS uses the step count to demonstrate Spezi's integration with HealthKit."; @@ -1181,6 +1211,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "PICS/Supporting Files/Info.plist"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; INFOPLIST_KEY_NSCameraUsageDescription = "This message should never appear. Please adjust this when you start using camera information. We have to put this in here as ResearchKit has the possibility to use it and not putting it here returns an error on AppStore Connect."; INFOPLIST_KEY_NSHealthShareUsageDescription = "The PICS uses the step count to demonstrate Spezi's integration with HealthKit."; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "The PICS uses the step count to demonstrate Spezi's integration with HealthKit."; @@ -1450,14 +1481,6 @@ minimumVersion = 0.3.5; }; }; - 2FE750CA2A87240100723EAE /* XCRemoteSwiftPackageReference "SpeziMockWebService" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/StanfordSpezi/SpeziMockWebService.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; - }; - }; 5661551B2AB8384200209B80 /* XCRemoteSwiftPackageReference "swift-package-list" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/FelixHerrmann/swift-package-list"; @@ -1474,6 +1497,14 @@ minimumVersion = 1.0.0; }; }; + A9F1F5FA2BA953F600A8E6DB /* XCRemoteSwiftPackageReference "swift-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-collections.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1576,21 +1607,11 @@ package = 2FE5DC9729EDD9D9004B9AB4 /* XCRemoteSwiftPackageReference "XCTestExtensions" */; productName = XCTestExtensions; }; - 2FF53D8A2A8725DE00042B76 /* SpeziMockWebService */ = { - isa = XCSwiftPackageProductDependency; - package = 2FE750CA2A87240100723EAE /* XCRemoteSwiftPackageReference "SpeziMockWebService" */; - productName = SpeziMockWebService; - }; 5661551C2AB8384200209B80 /* SwiftPackageList */ = { isa = XCSwiftPackageProductDependency; package = 5661551B2AB8384200209B80 /* XCRemoteSwiftPackageReference "swift-package-list" */; productName = SwiftPackageList; }; - 566155212AB83CF200209B80 /* SwiftPackageListJSONPlugin */ = { - isa = XCSwiftPackageProductDependency; - package = 5661551B2AB8384200209B80 /* XCRemoteSwiftPackageReference "swift-package-list" */; - productName = "plugin:SwiftPackageListJSONPlugin"; - }; 74AE05A82B9BB47F00AB5287 /* XCTSpezi */ = { isa = XCSwiftPackageProductDependency; package = 2F49B7742980407B00BCB272 /* XCRemoteSwiftPackageReference "Spezi" */; @@ -1616,6 +1637,16 @@ package = 2FE5DC7329EDD8E6004B9AB4 /* XCRemoteSwiftPackageReference "SpeziFirebase" */; productName = SpeziFirebaseAccountStorage; }; + A9F1F5F52BA94EAD00A8E6DB /* SwiftPackageListJSONPlugin */ = { + isa = XCSwiftPackageProductDependency; + package = 5661551B2AB8384200209B80 /* XCRemoteSwiftPackageReference "swift-package-list" */; + productName = "plugin:SwiftPackageListJSONPlugin"; + }; + A9F1F5FB2BA953F600A8E6DB /* OrderedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = A9F1F5FA2BA953F600A8E6DB /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = OrderedCollections; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 653A2545283387FE005D4D48 /* Project object */; diff --git a/PICS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/PICS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 05d7cd7..158de9c 100644 --- a/PICS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/PICS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "e3a25241397786f0f92600126ae926c27c41f21b0197ca6b9a97681904c02778", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -140,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordBDHG/ResearchKit", "state" : { - "revision" : "64512d0a0a5cc3e9d5b3fc5217c54f11d0dc044c", - "version" : "2.2.28" + "revision" : "6b28cdf0d06c3d6e96b5585369968b85deac96e0", + "version" : "2.2.29" } }, { @@ -203,17 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordSpezi/SpeziHealthKit.git", "state" : { - "revision" : "35628084d3977aa897015b0b0c21cfe4d556f1aa", - "version" : "0.5.2" - } - }, - { - "identity" : "spezimockwebservice", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziMockWebService.git", - "state" : { - "revision" : "b18067d3499e630bbd995ef05a296ef8fdd42528", - "version" : "1.0.0" + "revision" : "1e9cb5a6036ac7f4ff37ea1c3ed4898103339ad1", + "version" : "0.5.3" } }, { @@ -266,8 +258,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "c8ed701b513cf5177118a175d85fbbbcd707ab41", - "version" : "1.3.0" + "revision" : "46989693916f56d1186bd59ac15124caef896560", + "version" : "1.3.1" } }, { @@ -325,5 +317,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/PICS/Account/AccountSetupHeader.swift b/PICS/Account/AccountSetupHeader.swift index f8c12b5..1ae6171 100644 --- a/PICS/Account/AccountSetupHeader.swift +++ b/PICS/Account/AccountSetupHeader.swift @@ -11,9 +11,11 @@ import SwiftUI struct AccountSetupHeader: View { - @Environment(Account.self) private var account - @Environment(\._accountSetupState) private var setupState - + @Environment(Account.self) + private var account + @Environment(\._accountSetupState) + private var setupState + var body: some View { VStack { diff --git a/PICS/Account/AccountSheet.swift b/PICS/Account/AccountSheet.swift index 3e1784a..a056e8e 100644 --- a/PICS/Account/AccountSheet.swift +++ b/PICS/Account/AccountSheet.swift @@ -11,11 +11,14 @@ import SwiftUI struct AccountSheet: View { - @Environment(\.dismiss) var dismiss - - @Environment(Account.self) private var account - @Environment(\.accountRequired) var accountRequired - + @Environment(\.dismiss) + private var dismiss + + @Environment(Account.self) + private var account + @Environment(\.accountRequired) + private var accountRequired + @State var isInSetup = false @State var overviewIsEditing = false diff --git a/PICS/Appointment/AppointmentBlock.swift b/PICS/Appointment/AppointmentBlock.swift index bfb6f13..c668b15 100644 --- a/PICS/Appointment/AppointmentBlock.swift +++ b/PICS/Appointment/AppointmentBlock.swift @@ -9,73 +9,95 @@ import Foundation import SwiftUI struct Item: Identifiable, Hashable { - let name: String let id = UUID() + let name: LocalizedStringResource + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(name.key) + } } struct AppointmentBlock: View { - var date: String - var time: String - var items = [ - Item(name: String(localized: "REQUIRED_ITEMS_1")), - Item(name: String(localized: "REQUIRED_ITEMS_2")), - Item(name: String(localized: "REQUIRED_ITEMS_3")), - Item(name: String(localized: "REQUIRED_ITEMS_4")), - Item(name: String(localized: "REQUIRED_ITEMS_5")), - Item(name: String(localized: "REQUIRED_ITEMS_6")), - Item(name: String(localized: "REQUIRED_ITEMS_7")) + private let date: Date + + private let items = [ + Item(name: "REQUIRED_ITEMS_1"), + Item(name: "REQUIRED_ITEMS_2"), + Item(name: "REQUIRED_ITEMS_3"), + Item(name: "REQUIRED_ITEMS_4"), + Item(name: "REQUIRED_ITEMS_5"), + Item(name: "REQUIRED_ITEMS_6"), + Item(name: "REQUIRED_ITEMS_7") ] @State private var multiSelection = Set() @State private var showingSheet = false - - @Environment(AppointmentInformation.self) private var appointmentInfo var body: some View { - Color(UIColor.secondarySystemBackground) - .frame(height: 130) - .cornerRadius(15) - .overlay( - VStack(alignment: .leading) { + VStack(alignment: .center) { + VStack(alignment: .leading) { + HStack { + Text(formattedDate(date)) + .foregroundColor(.primary) + .bold() Spacer() - VStack(alignment: .leading) { - HStack { - Text(date) - .foregroundColor(.primary) - .bold() - Spacer() - Text(time) - .foregroundColor(.primary) - } - Spacer() - HStack { - Spacer() - Button(String(localized: "REQUIRED_ITEMS_HEADING")) { - showingSheet.toggle() - } - .buttonStyle(.bordered) - } - Spacer() + Text(formattedTime(date)) + .foregroundColor(.primary) + } + HStack { + Spacer() + Button("REQUIRED_ITEMS_HEADING") { + showingSheet.toggle() } - .padding() + .foregroundColor(.accentColor) + .buttonStyle(.plain) } - .sheet(isPresented: $showingSheet) { - NavigationView { - List(items, selection: $multiSelection) { - Text($0.name) - } - .navigationTitle(String(localized: "REQUIRED_ITEMS_HEADING")) + .padding(.top, 6) + } + } + .padding(.vertical, 8) + .sheet(isPresented: $showingSheet) { + NavigationStack { + List(items, selection: $multiSelection) { + Text($0.name) + } + .navigationTitle("REQUIRED_ITEMS_HEADING") .environment(\.editMode, .constant(.active)) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button(String(localized: "CLOSE")) { + Button("CLOSE") { showingSheet.toggle() } - .buttonStyle(.bordered) } } - } } - ) + } + } + + init(date: Date) { + self.date = date + } + + + private func formattedDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM dd, yyyy" + return formatter.string(from: date) + } + + private func formattedTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + return formatter.string(from: date) + } +} + + +#if DEBUG +#Preview { + List { + AppointmentBlock(date: .now.addingTimeInterval(60 * 60)) } } +#endif diff --git a/PICS/Appointment/AppointmentView.swift b/PICS/Appointment/AppointmentView.swift index 27b0f3b..f26fa94 100644 --- a/PICS/Appointment/AppointmentView.swift +++ b/PICS/Appointment/AppointmentView.swift @@ -9,7 +9,9 @@ import SwiftUI struct AppointmentView: View { - @Environment(AppointmentInformation.self) private var appointmentInfo + @Environment(PatientInformation.self) + private var patientInformation + @State private var showingEdit = false @State private var appt0User = Date() @@ -17,85 +19,81 @@ struct AppointmentView: View { @State private var appt2User = Date() var body: some View { - VStack(alignment: .center) { - VStack(alignment: .leading) { - AppointmentBlock(date: formattedDate(appointmentInfo.appt1), time: formattedTime(appointmentInfo.appt1)) - AppointmentBlock(date: formattedDate(appointmentInfo.appt2), time: formattedTime(appointmentInfo.appt2)) - .padding(.bottom) - HStack { - Button(String(localized: "RESCHEDULE_BUTTON_LABEL")) { - showingEdit.toggle() - } - .buttonStyle(.bordered) + List { + Section { + AppointmentBlock(date: patientInformation.appt1) + AppointmentBlock(date: patientInformation.appt2) + Button("RESCHEDULE_BUTTON_LABEL") { + showingEdit.toggle() } - .padding(.bottom) - Text(String(localized: "TIMELINE_TITLE")) - .foregroundColor(.primary) - .italic() - TimelineView() + } header: { + Circle() // workaround for weird spacing issue + .foregroundColor(Color.clear) + .frame(height: 0) } - .padding() - - Divider() - .overlay(Color.secondary) - .frame(width: 340) - - GettingThere() - Spacer() - } - .sheet(isPresented: $showingEdit) { - NavigationView { - VStack { - Text(String(localized: "APPTQ_0")) - .font(.headline) - .padding(.top, 32) - DateTimePickerView(selectedDateTime: $appt0User) - .padding(.bottom, 32) - Text(String(localized: "APPTQ_1")) - .font(.headline) - DateTimePickerView(selectedDateTime: $appt1User) - .padding(.bottom, 32) - Text(String(localized: "APPTQ_2")) - .font(.headline) - DateTimePickerView(selectedDateTime: $appt2User) - .padding(.bottom, 32) - Button(String(localized: "SAVE_BUTTON")) { - appointmentInfo.storeDates(appt0User, appt1User, appt2User) - showingEdit.toggle() - } - .buttonStyle(.bordered) - Spacer() + Section { + VStack(alignment: .leading) { + Text("TIMELINE_TITLE") + .foregroundColor(.primary) + .bold() + .padding(.bottom) + .accessibilityAddTraits(.isHeader) + TimelineView() } - .padding(.horizontal, 20) - .navigationTitle(String(localized: "EDIT_APPT_HEADER")) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(String(localized: "CLOSE")) { + .padding(.vertical, 8) + } + + Section { + GettingThere() + } + } + .navigationTitle("APPOINTMENTS_NAVIGATION_TITLE") + .sheet(isPresented: $showingEdit) { + NavigationStack { + List { + Text("APPTQ_0") + .font(.headline) + .padding(.top, 32) + DateTimePickerView(selectedDateTime: $appt0User) + .padding(.bottom, 32) + Text("APPTQ_1") + .font(.headline) + DateTimePickerView(selectedDateTime: $appt1User) + .padding(.bottom, 32) + Text("APPTQ_2") + .font(.headline) + DateTimePickerView(selectedDateTime: $appt2User) + .padding(.bottom, 32) + Button("SAVE_BUTTON") { + patientInformation.storeDates(appt0User, appt1User, appt2User) showingEdit.toggle() } - .buttonStyle(.bordered) + .buttonStyle(.borderedProminent) + Spacer() + } + .padding(.horizontal, 20) + .navigationTitle("EDIT_APPT_HEADER") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("CLOSE") { + showingEdit.toggle() + } + } } } + .interactiveDismissDisabled() } - } - .background(Color(UIColor.systemBackground)) } - - private func formattedDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "MMMM dd, yyyy" - return formatter.string(from: date) - } - - private func formattedTime(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" - return formatter.string(from: date) - } } + +#if DEBUG #Preview { - AppointmentView() - .environment(AppointmentInformation()) + NavigationStack { + AppointmentView() + .environment(PatientInformation()) + } } +#endif diff --git a/PICS/Appointment/Appointments.swift b/PICS/Appointment/Appointments.swift index ee74288..c85684e 100644 --- a/PICS/Appointment/Appointments.swift +++ b/PICS/Appointment/Appointments.swift @@ -11,20 +11,16 @@ import SwiftUI /// Displays the contacts for the PICS. struct Appointments: View { - @Environment(AppointmentInformation.self) private var appointmentInfo @Binding var presentingAccount: Bool var body: some View { NavigationStack { - ScrollView { - AppointmentView() - .navigationTitle(String(localized: "APPOINTMENTS_NAVIGATION_TITLE")) - .toolbar { - if AccountButton.shouldDisplay { - AccountButton(isPresented: $presentingAccount) - } + AppointmentView() + .toolbar { + if AccountButton.shouldDisplay { + AccountButton(isPresented: $presentingAccount) } - } + } } } @@ -38,6 +34,6 @@ struct Appointments: View { #if DEBUG #Preview { Appointments(presentingAccount: .constant(false)) - .environment(AppointmentInformation()) + .environment(PatientInformation()) } #endif diff --git a/PICS/Appointment/GettingThere.swift b/PICS/Appointment/GettingThere.swift index d7b3ab5..0005b07 100644 --- a/PICS/Appointment/GettingThere.swift +++ b/PICS/Appointment/GettingThere.swift @@ -9,26 +9,36 @@ import Foundation import SwiftUI + struct GettingThere: View { var body: some View { VStack(alignment: .leading) { - Text(String(localized: "GETTING_HERE_HEADING")) + Text("GETTING_HERE_HEADING") .font(.title2) + .bold() MapView() + .clipShape(RoundedRectangle(cornerRadius: 8)) .frame(height: 200) - Text(String(localized: "LOCATION_NAME")) + Text("LOCATION_NAME") .font(.title3) HStack { - Text(String(localized: "STREET_ADDRESS")) + Text("STREET_ADDRESS") Spacer() - Text(String(localized: "ZIP_CITY")) + Text("ZIP_CITY") } - .font(.subheadline) - .foregroundStyle(.secondary) - Text("") .font(.subheadline) - .foregroundStyle(.secondary) } - .padding() } } + + +#if DEBUG +#Preview { + NavigationStack { + List { + GettingThere() + } + .navigationTitle("Test Title") + } +} +#endif diff --git a/PICS/Appointment/MapView.swift b/PICS/Appointment/MapView.swift index 12a8b8e..d468108 100644 --- a/PICS/Appointment/MapView.swift +++ b/PICS/Appointment/MapView.swift @@ -8,19 +8,70 @@ import Contacts import MapKit +import OSLog import SwiftUI + struct MapView: View { - var body: some View { - Map(initialPosition: .region(region)) + private let logger = Logger(subsystem: "de.charité", category: "MapView") + + @State private var searchResult: MKMapItem? + @State private var showOpenConfirmation = false + + private var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: 52.542330, longitude: 13.347440) } - + private var region: MKCoordinateRegion { MKCoordinateRegion( - center: CLLocationCoordinate2D(latitude: 52.542330, longitude: 13.347440), + center: coordinate, span: MKCoordinateSpan(latitudeDelta: 0.002, longitudeDelta: 0.002) ) } + + private var mapItem: MKMapItem { + let address = CNMutablePostalAddress() + address.country = "Germany" + address.postalCode = "13353" + address.city = "Berlin" + address.street = "Augustenburger Pl. 1" + + let placemark = MKPlacemark(coordinate: coordinate, postalAddress: address) + return MKMapItem(placemark: placemark) + } + + var body: some View { + Map(initialPosition: .region(region), interactionModes: [.zoom, .rotate]) { + Marker(item: searchResult ?? mapItem) + } + .confirmationDialog("Get Directions", isPresented: $showOpenConfirmation) { + Button("Open in Apple Maps") { + (searchResult ?? mapItem).openInMaps() + } + Button("Cancel", role: .cancel) {} + } + .onTapGesture { + showOpenConfirmation = true + } + .task { + let searchRequest = MKLocalSearch.Request() + searchRequest.naturalLanguageQuery = "Charité" + searchRequest.resultTypes = .pointOfInterest + searchRequest.region = region + + let search = MKLocalSearch(request: searchRequest) + do { + let response = try await search.start() + searchResult = response.mapItems.first { item in + item.name == "Charité" + && item.pointOfInterestCategory == .hospital + } + } catch { + // we just show our backup point + logger.warning("Failed to query Charité point of interest: \(error)") + } + } + } } #Preview { diff --git a/PICS/Appointment/TimelineView.swift b/PICS/Appointment/TimelineView.swift index 4d82c8c..56ee830 100644 --- a/PICS/Appointment/TimelineView.swift +++ b/PICS/Appointment/TimelineView.swift @@ -90,29 +90,31 @@ struct BidirectionalArrow: Shape { struct TimelineView: View { - @Environment(AppointmentInformation.self) private var appointmentInfo - + @Environment(PatientInformation.self) + private var patientInformation + var body: some View { let calendar = Calendar.current let currentDate = Date() - let components1 = calendar.dateComponents([.year, .month, .day], from: appointmentInfo.appt1) - let components2 = calendar.dateComponents([.year, .month, .day], from: appointmentInfo.appt2) + let components1 = calendar.dateComponents([.year, .month, .day], from: patientInformation.appt1) + let components2 = calendar.dateComponents([.year, .month, .day], from: patientInformation.appt2) let components3 = calendar.dateComponents([.year, .month, .day], from: currentDate) - if let date1 = calendar.date(from: components1), let date2 = calendar.date(from: components2), let now = calendar.date(from: components3) { + if let date1 = calendar.date(from: components1), + let date2 = calendar.date(from: components2), + let now = calendar.date(from: components3) { VStack { BidirectionalArrow() .fill(Color.primary) .stroke(Color.primary, lineWidth: 2) - .frame(width: 361, height: 25) .overlay( CurrentLocation(date1: date1, date2: date2, now: now) .fill(Color.accentColor) ) HStack(spacing: 0) { Spacer() - Text(String(localized: "APPT1_TITLE")) + Text("APPT1_TITLE") .padding(.leading, 45) .foregroundColor(.primary) Spacer() @@ -121,7 +123,7 @@ struct TimelineView: View { .foregroundColor(.primary) Spacer() } - .font(.system(size: 12)) + .font(.footnote) .foregroundColor(.primary) } } else { @@ -130,3 +132,13 @@ struct TimelineView: View { } } } + + +#if DEBUG +#Preview { + List { + TimelineView() + .environment(PatientInformation()) + } +} +#endif diff --git a/PICS/Assessment/Assessments.swift b/PICS/Assessment/Assessments.swift index dd6ec8a..46e812d 100644 --- a/PICS/Assessment/Assessments.swift +++ b/PICS/Assessment/Assessments.swift @@ -8,264 +8,59 @@ import SwiftUI -// Struct for all the Assessments. The positive errorCnt and score -// will be plot in ResultsViz. -struct AssessmentResult: Codable, Identifiable { - var testDateTime: Date - var timeSpent: Double - var errorCnt: Int = -1 - // Score that might be collected for MiniCog. - var score: Int = -1 - var id = UUID() -} struct Assessments: View { - // Enum to differentiate between available assessments. - enum Assessments { - case trailMaking - case stroopTest - case reactionTime - } - - // Binding to control the display of account-related UI. + @State private var assessmentResults = AssessmentResults() + @State private var presentedTask: AssessmentTask? + @Binding private var presentingAccount: Bool - - // Local storage of results of the Trail Making and Stroop tests for test results for layerplotting, analysis etc. - @AppStorage("trailMakingResults") private var tmStorageResults: [AssessmentResult] = [] - @AppStorage("stroopTestResults") private var stroopTestResults: [AssessmentResult] = [] - @AppStorage("reactionTimeResults") private var reactionTimeResults: [AssessmentResult] = [] - // Tracks which test is currently selected. - @State var currentTest = Assessments.trailMaking - // New property to control the sheet presentation - @State private var showingTestSheet = false - - // Main body of the Assessments view, switching between the list of assessments and the currently active assessment. - var assessmentList: some View { - List { - trailMakingTestSection - .padding(10) - stroopTestSection - .padding(10) - reactionTimeSection - .padding(10) - } - } - var body: some View { NavigationStack { - assessmentList - .navigationTitle(String(localized: "ASSESSMENTS_NAVIGATION_TITLE")) + List { + AssessmentTaskSection(task: .trailMaking, presentingTask: $presentedTask) + AssessmentTaskSection(task: .stroopTest, presentingTask: $presentedTask) + AssessmentTaskSection(task: .reactionTime, presentingTask: $presentedTask) + } + .navigationTitle("ASSESSMENTS_NAVIGATION_TITLE") .toolbar { if AccountButton.shouldDisplay { AccountButton(isPresented: $presentingAccount) } } } - .sheet(isPresented: $showingTestSheet) { - // Determine which assessment view to present based on the currentTest state - switch currentTest { - case .trailMaking: - TrailMakingTaskView() - case .stroopTest: - StroopTestView() - case .reactionTime: - ReactionTimeView() - } - } - .onAppear { - if FeatureFlags.mockTestData { - // Set mock test data for --mockTestData feature data. - let resultsWithTimeError = AssessmentResult(testDateTime: Date(), timeSpent: 10, errorCnt: 5) - tmStorageResults = [resultsWithTimeError] - stroopTestResults = [resultsWithTimeError] - // We only record time spent for reactionTimeResults. - reactionTimeResults = [AssessmentResult(testDateTime: Date(), timeSpent: 10)] - } - } - } - - private var trailMakingTestSection: some View { - // Button text to start the Trail Making Test or view results based on whether results are available. - let btnText = if tmStorageResults.isEmpty { - String(localized: "ASSESSMENT_TM_START_BTN") - } else { - String(localized: "ASSESSMENT_RESULTS_BTN") - } - return Section { - VStack { - trailMakingTestResultsView - Divider() - .padding(.bottom, 5) - Button(action: startTrailMaking) { - Text(btnText) - .foregroundStyle(.accent) - } - .accessibility(identifier: "startTrailMakingTestButton") - // Use style to restrict clickable area. - .buttonStyle(.plain) - } - } - } - - private var stroopTestSection: some View { - // Button text to start the Stroop Test or view results based on whether results are available. - let btnText = if stroopTestResults.isEmpty { - String(localized: "ASSESSMENT_STROOP_START_BTN") - } else { - String(localized: "ASSESSMENT_RESULTS_BTN") - } - return Section { - VStack { - stroopTestResultsView - Divider() - .padding(.bottom, 5) - Button(action: startStroopTest) { - Text(btnText) - .foregroundStyle(.accent) + .onAppear { + if FeatureFlags.mockTestData { + assessmentResults.setTestMockData() } - .accessibility(identifier: "startStroopTestButton") - // Use style to restrict clickable area. - .buttonStyle(.plain) } - } - } - private var reactionTimeSection: some View { - // Button text to start the ReactionTime Test or view results - // based on whether results are available. - let btnText = if reactionTimeResults.isEmpty { - String(localized: "ASSESSMENT_STROOP_START_BTN") - } else { - String(localized: "ASSESSMENT_RESULTS_BTN") - } - return Section { - VStack { - reactionTimeResultsView - Divider() - .padding(.bottom, 5) - Button(action: startReactionTimeTest) { - Text(btnText) - .foregroundStyle(.accent) + .sheet(item: $presentedTask) { task in + Group { + switch task { + case .trailMaking: + TrailMakingTaskView() + case .stroopTest: + StroopTestView() + case .reactionTime: + ReactionTimeView() + } } - .accessibility(identifier: "startReactimeTestButton") - // Use style to restrict clickable area. - .buttonStyle(.plain) + .background(Color(uiColor: .systemGroupedBackground)) // fix ResearchKit background color + .interactiveDismissDisabled() } - } - } - - // Views for displaying results of Trail Making, or a message indicating the test has not been completed. - private var trailMakingTestResultsView: some View { - Group { - if tmStorageResults.isEmpty { - notCompletedView(testName: "Trail Making") - } else { - ResultsViz( - data: tmStorageResults, - xName: "Time", - yName: "Results", - title: String(localized: "TM_VIZ_TITLE") - ) - } - } + .environment(assessmentResults) } - - // Views for displaying results of the Stroop test, or a message indicating the test has not been completed. - private var stroopTestResultsView: some View { - Group { - if stroopTestResults.isEmpty { - notCompletedView(testName: "Stroop Test") - } else { - ResultsViz( - data: stroopTestResults, - xName: "Time", - yName: "Results", - title: String(localized: "STROOP_VIZ_TITLE") - ) - } - } - } - // Views for displaying results of the ReactionTime test, or a message indicating the test has not been completed. - private var reactionTimeResultsView: some View { - Group { - if reactionTimeResults.isEmpty { - notCompletedView(testName: "Reaction Time") - } else { - ResultsViz( - data: reactionTimeResults, - xName: "Time", - yName: "Results", - title: String(localized: "REACTIONTIME_VIZ_TITLE") - ) - } - } - } - - // Initializes the view with a binding to control whether the account UI is being presented. + + init(presentingAccount: Binding) { self._presentingAccount = presentingAccount } - - // Function to set up and start the Trail Making assessment. - func startTrailMaking() { - currentTest = Assessments.trailMaking - showingTestSheet = true - } - - // Function to set up and start the Stroop Test. - func startStroopTest() { - currentTest = Assessments.stroopTest - showingTestSheet = true - } - // Function to set up and start the ReactionTime Test. - func startReactionTimeTest() { - currentTest = Assessments.reactionTime - showingTestSheet = true - } - - private func testDescription(for testName: String) -> String { - switch testName { - case "Trail Making": - return "visual attention and task switching" - case "Stroop Test": - return "cognitive flexibility and processing speed" - case "Reaction Time": - return "speed and accuracy of response" - default: - return "unknown skill" - } - } - - // A view for displaying a message indicating that a specific assessment has not been completed. - private func notCompletedView(testName: String) -> some View { - VStack(alignment: .leading) { - HStack { - // Displays a warning icon and a message stating the test has not been completed. - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(.red) - .font(.system(size: 25)) - .accessibilityHidden(true) - Text("\(testName): Not Completed") - .font(.title3.bold()) - .foregroundColor(.primary) - .lineLimit(1) - .minimumScaleFactor(0.5) - } - .padding(.bottom, 5) - .multilineTextAlignment(.center) - Text("This test measures your \(testDescription(for: testName)).") - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .font(.subheadline) - .padding(.bottom, 5) - } - } } + #if DEBUG #Preview("AssessmentView") { Assessments(presentingAccount: .constant(false)) - .environment(AppointmentInformation()) + .environment(PatientInformation()) } #endif diff --git a/PICS/Assessment/Model/AssessmentResult.swift b/PICS/Assessment/Model/AssessmentResult.swift new file mode 100644 index 0000000..330bb28 --- /dev/null +++ b/PICS/Assessment/Model/AssessmentResult.swift @@ -0,0 +1,27 @@ +// +// This source file is part of the PICS based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +struct AssessmentResult: Codable, Identifiable { + let id: UUID + + let testDateTime: Date + let timeSpent: Double + let errorCnt: Int? + + + init(id: UUID = UUID(), testDateTime: Date, timeSpent: Double, errorCnt: Int? = nil) { + // swiftlint:disable:previous function_default_parameter_at_end + self.id = id + self.testDateTime = testDateTime + self.timeSpent = timeSpent + self.errorCnt = errorCnt + } +} diff --git a/PICS/Assessment/Model/AssessmentResults.swift b/PICS/Assessment/Model/AssessmentResults.swift new file mode 100644 index 0000000..381f280 --- /dev/null +++ b/PICS/Assessment/Model/AssessmentResults.swift @@ -0,0 +1,66 @@ +// +// This source file is part of the PICS based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +@Observable +class AssessmentResults { + @AppStorage("trailMakingResults") + @ObservationIgnored private var _tmStorageResults: [AssessmentResult] = [] + @AppStorage("stroopTestResults") + @ObservationIgnored private var _stroopTestResults: [AssessmentResult] = [] + @AppStorage("reactionTimeResults") + @ObservationIgnored private var _reactionTimeResults: [AssessmentResult] = [] + + var tmStorageResults: [AssessmentResult] { + get { + access(keyPath: \.tmStorageResults) + return _tmStorageResults + } + set { + withMutation(keyPath: \.tmStorageResults) { + _tmStorageResults = newValue + } + } + } + + var stroopTestResults: [AssessmentResult] { + get { + access(keyPath: \.stroopTestResults) + return _stroopTestResults + } + set { + withMutation(keyPath: \.stroopTestResults) { + _stroopTestResults = newValue + } + } + } + + var reactionTimeResults: [AssessmentResult] { + get { + access(keyPath: \.reactionTimeResults) + return _reactionTimeResults + } + set { + withMutation(keyPath: \.reactionTimeResults) { + _reactionTimeResults = newValue + } + } + } + + + func setTestMockData() { + // Set mock test data for --mockTestData feature data. + let resultsWithTimeError = AssessmentResult(testDateTime: Date(), timeSpent: 10, errorCnt: 5) + tmStorageResults = [resultsWithTimeError] + stroopTestResults = [resultsWithTimeError] + // We only record time spent for reactionTimeResults. + reactionTimeResults = [AssessmentResult(testDateTime: Date(), timeSpent: 10)] + } +} diff --git a/PICS/Assessment/Model/AssessmentTask.swift b/PICS/Assessment/Model/AssessmentTask.swift new file mode 100644 index 0000000..c777365 --- /dev/null +++ b/PICS/Assessment/Model/AssessmentTask.swift @@ -0,0 +1,82 @@ +// +// This source file is part of the PICS based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +// Enum to differentiate between available assessments. +enum AssessmentTask: String { + case trailMaking + case stroopTest + case reactionTime +} + + +extension AssessmentTask { + var accessibilityIdentifier: String { + switch self { + case .trailMaking: + "startTrailMakingTestButton" + case .stroopTest: + "startStroopTestButton" + case .reactionTime: + "startReactimeTestButton" + } + } + + var resultsKey: KeyPath { + switch self { + case .trailMaking: + \.tmStorageResults + case .stroopTest: + \.stroopTestResults + case .reactionTime: + \.reactionTimeResults + } + } + + var testName: LocalizedStringResource { + switch self { + case .trailMaking: + "Trail Making" + case .stroopTest: + "Stroop Test" + case .reactionTime: + "Reaction Time" + } + } + + var testDescription: LocalizedStringResource { + switch self { + case .trailMaking: + "This test measures your visual attention and task switching." + case .stroopTest: + "This test measures your cognitive flexibility and processing speed." + case .reactionTime: + "This test measures your speed and accuracy of response." + } + } + + var resultsTitle: LocalizedStringResource { + switch self { + case .trailMaking: + "TM_VIZ_TITLE" + case .stroopTest: + "STROOP_VIZ_TITLE" + case .reactionTime: + "REACTIONTIME_VIZ_TITLE" + } + } +} + + +extension AssessmentTask: Identifiable { + var id: String { + rawValue + } +} diff --git a/PICS/Assessment/DeviceMotionModel.swift b/PICS/Assessment/Model/DeviceMotionModel.swift similarity index 100% rename from PICS/Assessment/DeviceMotionModel.swift rename to PICS/Assessment/Model/DeviceMotionModel.swift diff --git a/PICS/Assessment/ReactionTime.swift b/PICS/Assessment/ReactionTime.swift deleted file mode 100644 index 923488c..0000000 --- a/PICS/Assessment/ReactionTime.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// This source file is part of the PICS based on the Stanford Spezi Template Application project -// -// SPDX-FileCopyrightText: 2024 Stanford University -// -// SPDX-License-Identifier: MIT -// - -import ResearchKit -import ResearchKitSwiftUI -import SwiftUI - -struct ReactionTimeView: View { - @AppStorage("reactionTimeResults") private var reactionTimeResults: [AssessmentResult] = [] - @Environment(\.presentationMode) var presentationMode - - var body: some View { - ZStack { - Color(red: 242 / 255, green: 242 / 255, blue: 247 / 255) - .edgesIgnoringSafeArea(.all) - // Displays the ResearchKit ordered task view for the ReactionTime Test. - ORKOrderedTaskView( - tasks: createReactionTimeTask(), - tintColor: .accentColor, - shouldConfirmCancel: true, - result: handleTaskResult - ) - } - } - - // Creates the ReactionTime task to be presented to the user - private func createReactionTimeTask() -> ORKOrderedTask { - // Initializes a ReactionTime task with the following specified parameters - let task = ORKOrderedTask.reactionTime( - withIdentifier: "ReactionTimeTask", - intendedUseDescription: nil, - maximumStimulusInterval: 2.0, - minimumStimulusInterval: 1.0, - thresholdAcceleration: 0.8, - numberOfAttempts: 5, - timeout: 5.0, - successSound: 0, - timeoutSound: 0, - failureSound: 0, - options: [.excludeConclusion] - ) - return task - } - // Handles the result of the ReactionTime task. - private func handleTaskResult(result: TaskResult) async { - // Close the test by dismissing the view - DispatchQueue.main.async { - self.presentationMode.wrappedValue.dismiss() - } - - guard case let .completed(taskResult) = result else { - // Failed or canceled test. Do nothing for current. - return - } - // Fields to record the aggregated test results. - var totalTime: TimeInterval = 0 - // Extract and process the ReactionTime test results. - for result in taskResult.results ?? [] { - if let stepResult = result as? ORKStepResult, - stepResult.identifier == "reactionTime" { - for reactionTimeResult in stepResult.results ?? [] { - if let curResult = reactionTimeResult as? ORKReactionTimeResult { - #if targetEnvironment(simulator) - // adding fixed size because ReactionTime does not work on simulator - totalTime += 0.5 - #else - // get DeviceMotion data to find elapsed time - let jsonURL = curResult.fileResult.fileURL - if let jsonURL = jsonURL, let jsonData = try? Data(contentsOf: jsonURL) { - let decoder = JSONDecoder() - do { - let deviceMotion = try decoder.decode(DeviceMotion.self, from: jsonData) - if let lastItem = deviceMotion.items.last { - totalTime += (lastItem.timestamp - curResult.timestamp) - } - } catch { - print("Error parsing JSON:", error) - } - } - #endif - } - } - } - } - reactionTimeResults += [AssessmentResult(testDateTime: Date(), timeSpent: totalTime / 5)] - } -} - -#Preview { - ReactionTimeView() -} diff --git a/PICS/Assessment/ResultsViz.swift b/PICS/Assessment/ResultsViz.swift deleted file mode 100644 index 6514a21..0000000 --- a/PICS/Assessment/ResultsViz.swift +++ /dev/null @@ -1,229 +0,0 @@ -// -// This source file is part of the PICS based on the Stanford Spezi Template Application project -// -// SPDX-FileCopyrightText: 2024 Stanford University -// -// SPDX-License-Identifier: MIT -// - -import Charts -import SwiftUI - -struct ResultsViz: View { - var data: [AssessmentResult] = [] - var xName: String - var yName: String - var title: String - - // Vars for plotting. - var metricType: String - var metricEmpty: Bool // Whether we have result recorded for metric. - var timeSpentLable = String(localized: "ASSESSMENT_VIZ_TIME") - let metricColor = Color.purple - let timeColor = Color.teal - let unSelectedColor = Color.gray - - // Vars to show detailed results. - @State private var selectedElement: AssessmentResult? - - var body: some View { - Text(self.title) - .font(.title3.bold()) - .padding(5) - - // Helper text to show data when clicked or for the last result. - let details = getResultDetails() - if !details.isEmpty { - Text(details) - .font(.footnote) - .frame(maxWidth: .infinity, alignment: .leading) - .listRowSeparator(.hidden) - } - - chart - } - - private var chart: some View { - Chart { - ForEach(data) { dataPoint in - // We assume that metric might be only one of error count or score. - let selected = if let elm = self.selectedElement { - elm.testDateTime == dataPoint.testDateTime - } else { - false - } - let yValue = if self.metricType == String(localized: "ASSESSMENT_VIZ_SCORE") { - dataPoint.score - } else { - dataPoint.errorCnt - } - if yValue >= 0 { - getLineMarker(time: dataPoint.testDateTime, selected: selected, yMark: self.metricType, yMetric: yValue) - } - - getLineMarker(time: dataPoint.testDateTime, selected: selected, yMark: self.timeSpentLable, yTime: dataPoint.timeSpent) - } - .lineStyle(StrokeStyle(lineWidth: 2.0)) - } - .chartForegroundStyleScale( - self.metricEmpty ? [self.timeSpentLable: self.timeColor] : [self.timeSpentLable: self.timeColor, self.metricType: self.metricColor] - ) - .padding(10) - .chartXAxis { - AxisMarks(values: .automatic(desiredCount: 3)) - } - // Use overlay to detect user's selection on marks. - .chartOverlay { proxy in - GeometryReader { geo in - // The rectangle to detect user's clicked position and bar. - Rectangle() - .fill(.clear) - .contentShape(Rectangle()) - .gesture( - SpatialTapGesture() - .onEnded { value in - let element = findElement(location: value.location, proxy: proxy, geometry: geo) - if selectedElement?.testDateTime == element?.testDateTime { - // If tapping the same element, clear the selection. - selectedElement = nil - } else { - selectedElement = element - } - } - .exclusively( - before: DragGesture() - .onChanged { value in - selectedElement = findElement(location: value.location, proxy: proxy, geometry: geo) - } - ) - ) - } - } - } - - init(data: [AssessmentResult], xName: String, yName: String, title: String) { - self.data = data - self.xName = xName - self.yName = yName - self.title = title - // We assume that we only need to plot one of error count or score. - let errorCntMax = data.map(\.errorCnt).max() ?? -1 - let scoreMax = data.map(\.score).max() ?? -1 - self.metricType = errorCntMax == -1 ? String(localized: "ASSESSMENT_VIZ_SCORE") : String(localized: "ASSESSMENT_VIZ_ERRORCNT") - self.metricEmpty = (max(scoreMax, errorCntMax) == -1) - } - - private func findElement(location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> AssessmentResult? { - guard let proxyF = proxy.plotFrame else { - print("Failed to get proxy plotframe") - return nil - } - let relativeXPosition = location.x - geometry[proxyF].origin.x - if let date = proxy.value(atX: relativeXPosition) as Date? { - // Find the closest date element. - var minDistance: TimeInterval = .infinity - var index: Int? - for dataIndex in data.indices { - let nthDataDistance = data[dataIndex].testDateTime.distance(to: date) - if abs(nthDataDistance) < minDistance { - minDistance = abs(nthDataDistance) - index = dataIndex - } - } - if let index { - return data[index] - } - } - return nil - } - - // Get the helper text for result to show. It should be - // the selected or the last result if available. - private func getResultDetails() -> String { - var prefix = "" - var elm: AssessmentResult - if let elmSel = self.selectedElement { - elm = elmSel - prefix = String(localized: "ASSESSMENT_SUMMARY") - } else if let elmLast = data.last { - elm = elmLast - prefix = String(localized: "ASSESSMENT_SUMMARY_LAST") - } else { - // No availabe element to show. - return "" - } - - // Round the time to 2 decimal points. - let timeSpent = round(elm.timeSpent * 10) / 10 - let metricNum = if self.metricType == String(localized: "ASSESSMENT_VIZ_SCORE") { - elm.score - } else { - elm.errorCnt - } - let metricText = if metricNum < 0 { - "" - } else { - ", " + - self.metricType + - ": " + - String(metricNum) - } - return ( - prefix + - String(elm.testDateTime.formatted(.dateTime.year().month().day())) + - ":\n" + - self.timeSpentLable + - ": " + - String(timeSpent) + - metricText - ) - } - - // Build the line marker for time spent and metric. - private func getLineMarker(time: Date, selected: Bool, yMark: String, yMetric: Int = -1, yTime: Double = 0) -> some ChartContent { - let markColor = if yMark == self.metricType { - (self.selectedElement == nil) ? self.metricColor : self.unSelectedColor - } else { - (self.selectedElement == nil) ? self.timeColor : self.unSelectedColor - } - - let mark = if yMark == self.metricType { - LineMark( - x: .value(self.xName, time, unit: .second), - y: .value(self.metricType, yMetric), - series: .value("Value", self.metricType) - ) - .foregroundStyle(markColor) - } else { - LineMark( - x: .value(self.xName, time, unit: .second), - y: .value(self.timeSpentLable, yTime), - series: .value("Value", self.timeSpentLable) - ) - .foregroundStyle(markColor) - } - let highlightColor = if yMark == self.metricType { - self.metricColor - } else { - self.timeColor - } - - // Highlight the seletion. - return mark.symbol { - if selected { - Circle() - .strokeBorder(highlightColor, lineWidth: 2) - .background(Circle().foregroundColor(highlightColor)) - .frame(width: 10) - } else { - Circle() - .strokeBorder(markColor, lineWidth: 2) - .frame(width: 5) - } - } - } -} - -#Preview { - ResultsViz(data: [], xName: "preview xName", yName: "preview yName", title: "preview Title") -} diff --git a/PICS/Assessment/StroopTest.swift b/PICS/Assessment/StroopTest.swift deleted file mode 100644 index 63b0e6e..0000000 --- a/PICS/Assessment/StroopTest.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// This source file is part of the PICS based on the Stanford Spezi Template Application project -// -// SPDX-FileCopyrightText: 2024 Stanford University -// -// SPDX-License-Identifier: MIT -// - -import ResearchKit -import ResearchKitSwiftUI -import SwiftUI - -struct StroopTestView: View { - @AppStorage("stroopTestResults") private var stroopTestResults: [AssessmentResult] = [] - - @Environment(\.presentationMode) var presentationMode - - var body: some View { - ZStack { - Color(red: 242 / 255, green: 242 / 255, blue: 247 / 255) - .edgesIgnoringSafeArea(.all) - // Displays the ResearchKit ordered task view for the Stroop Test. - ORKOrderedTaskView( - tasks: createStroopTask(), - tintColor: .accentColor, - shouldConfirmCancel: true, - result: handleTaskResult - ) - .navigationBarTitle("STROOP_TEST_TITLE") - } - } - - // Creates the Stroop task to be presented to the user - private func createStroopTask() -> ORKOrderedTask { - // Initializes a Stroop task with the following specified parameters - let task = ORKOrderedTask.stroopTask( - withIdentifier: "StroopTask", - intendedUseDescription: "Tests selective attention capacity and processing speed", - numberOfAttempts: 5, - options: [.excludeAudio] - ) - return task - } - - // Handles the result of the Stroop task. - private func handleTaskResult(result: TaskResult) async { - // Close the test by dismissing the view - DispatchQueue.main.async { - self.presentationMode.wrappedValue.dismiss() - } - - guard case let .completed(taskResult) = result else { - return - } - // Fields to record the aggregated test results. - var totalTime: TimeInterval = 0 - var errorCnt = 0 - - // Extract and process the Stroop test results. - for result in taskResult.results ?? [] { - if let stepResult = result as? ORKStepResult, - stepResult.identifier == "stroop" { - for stroopResult in stepResult.results ?? [] { - if let curResult = stroopResult as? ORKStroopResult { - // Calculates the total time taken to complete the test. - totalTime += curResult.endTime - curResult.startTime - if curResult.color != curResult.colorSelected { - errorCnt += 1 - } - } - } - } - } - // Record the result to the app storage of Stroop test results. - let parsedResult = AssessmentResult( - testDateTime: Date(), - timeSpent: totalTime, - errorCnt: errorCnt - ) - stroopTestResults += [parsedResult] - } -} - -#Preview { - StroopTestView() -} diff --git a/PICS/Assessment/Tests/ReactionTime.swift b/PICS/Assessment/Tests/ReactionTime.swift new file mode 100644 index 0000000..e3b1ca1 --- /dev/null +++ b/PICS/Assessment/Tests/ReactionTime.swift @@ -0,0 +1,114 @@ +// +// This source file is part of the PICS based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import ResearchKit +import ResearchKitSwiftUI +import SwiftUI + +struct ReactionTimeView: View { + @Environment(AssessmentResults.self) + private var results + + @Environment(\.dismiss) + var dismiss + + + private var reactionTimeTask: ORKOrderedTask { + .reactionTime( + withIdentifier: "ReactionTimeTask", + intendedUseDescription: nil, + maximumStimulusInterval: 2.0, + minimumStimulusInterval: 1.0, + thresholdAcceleration: 0.8, + numberOfAttempts: 5, + timeout: 5.0, + successSound: 0, + timeoutSound: 0, + failureSound: 0, + options: [.excludeConclusion] + ) + } + + var body: some View { + ORKOrderedTaskView( + tasks: reactionTimeTask, + result: handleTaskResult + ) + } + + + // Handles the result of the ReactionTime task. + private func handleTaskResult(result: TaskResult) { + dismiss() + + guard case let .completed(taskResult) = result else { + return // Failed or canceled test. Do nothing for current. + } + + guard let taskResults = taskResult.results else { + return + } + + // Fields to record the aggregated test results. + var totalTime: TimeInterval = 0 + var steps = 0 + + // Extract and process the ReactionTime test results. + for result in taskResults { + guard let stepResult = result as? ORKStepResult, + stepResult.identifier == "reactionTime", + let stepResults = stepResult.results else { + continue + } + + for stepResult in stepResults { + guard let reactionTimeResult = stepResult as? ORKReactionTimeResult else { + continue + } +#if targetEnvironment(simulator) + // adding fixed size because ReactionTime does not work on simulator + totalTime += 0.5 + steps += 1 +#else + // get DeviceMotion data to find elapsed time + guard let jsonURL = reactionTimeResult.fileResult.fileURL else { + continue + } + + do { + let jsonData = try Data(contentsOf: jsonURL) + let decoder = JSONDecoder() + + let deviceMotion = try decoder.decode(DeviceMotion.self, from: jsonData) + if let lastItem = deviceMotion.items.last { + totalTime += (lastItem.timestamp - reactionTimeResult.timestamp) + steps += 1 + } + } catch { + print("Error parsing JSON:", error) + } +#endif + } + } + + guard steps > 0 else { + return + } + + let result = AssessmentResult(testDateTime: Date(), timeSpent: totalTime / Double(steps)) + results.reactionTimeResults.append(result) + } +} + + +#if DEBUG +#Preview { + ReactionTimeView() + .environment(AssessmentResults()) +} +#endif diff --git a/PICS/Assessment/Tests/StroopTest.swift b/PICS/Assessment/Tests/StroopTest.swift new file mode 100644 index 0000000..7d8e7eb --- /dev/null +++ b/PICS/Assessment/Tests/StroopTest.swift @@ -0,0 +1,88 @@ +// +// This source file is part of the PICS based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import ResearchKit +import ResearchKitSwiftUI +import SwiftUI + +struct StroopTestView: View { + @Environment(AssessmentResults.self) + private var results + + @Environment(\.dismiss) + var dismiss + + private var stroopTask: ORKOrderedTask { + .stroopTask( + withIdentifier: "StroopTask", + intendedUseDescription: "Tests selective attention capacity and processing speed", + numberOfAttempts: 5, + options: [.excludeAudio] + ) + } + + var body: some View { + ORKOrderedTaskView( + tasks: stroopTask, + result: handleTaskResult + ) + } + + private func handleTaskResult(result: TaskResult) { + dismiss() + + guard case let .completed(taskResult) = result else { + return + } + + guard let taskResults = taskResult.results else { + return + } + + // Fields to record the aggregated test results. + var totalTime: TimeInterval = 0 + var errorCnt = 0 + + // Extract and process the Stroop test results. + for result in taskResults { + guard let stepResult = result as? ORKStepResult, + stepResult.identifier == "stroop" else { + continue + } + + for stepResult in stepResult.results ?? [] { + guard let stroppResult = stepResult as? ORKStroopResult else { + continue + } + + // Calculates the total time taken to complete the test. + totalTime += stroppResult.endTime - stroppResult.startTime + if stroppResult.color != stroppResult.colorSelected { + errorCnt += 1 + } + } + } + + // Record the result to the app storage of Stroop test results. + let parsedResult = AssessmentResult( + testDateTime: Date(), + timeSpent: totalTime, + errorCnt: errorCnt + ) + + results.stroopTestResults.append(parsedResult) + } +} + + +#if DEBUG +#Preview { + StroopTestView() + .environment(AssessmentResults()) +} +#endif diff --git a/PICS/Assessment/Tests/TrailMakingTest.swift b/PICS/Assessment/Tests/TrailMakingTest.swift new file mode 100644 index 0000000..f566510 --- /dev/null +++ b/PICS/Assessment/Tests/TrailMakingTest.swift @@ -0,0 +1,92 @@ +// +// This source file is part of the PICS based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import ResearchKit +import ResearchKitSwiftUI +import SwiftUI + + +struct TrailMakingTaskView: View { + @Environment(AssessmentResults.self) + private var results + + @Environment(\.dismiss) + var dismiss + + private var trailMakingTask: ORKOrderedTask { + .trailmakingTask( + withIdentifier: "TrailMakingTask", + intendedUseDescription: "Tests visual attention and task switching", + trailmakingInstruction: nil, + trailType: .B, + options: [] + ) + } + + var body: some View { + ORKOrderedTaskView( + tasks: trailMakingTask, + tintColor: .accentColor, + cancelBehavior: .shouldConfirmCancel, + result: handleTaskResult + ) + } + + private func handleTaskResult(result: TaskResult) { + dismiss() + + guard case let .completed(taskResult) = result else { + return + } + + let parsedResult = parseTMResult(taskResult: taskResult) + if let nonEmptyResult = parsedResult { + results.tmStorageResults.append(nonEmptyResult) + } + } +} + + +func parseTMResult(taskResult: ORKTaskResult) -> AssessmentResult? { + guard let taskResults = taskResult.results else { + return nil + } + + for result in taskResults { + guard let stepResult = result as? ORKStepResult, + stepResult.identifier == "trailmaking" else { + continue + } + + for stepResult in stepResult.results ?? [] { + guard let trailMakingResult = stepResult as? ORKTrailmakingResult else { + continue + } + + guard let timestamp = trailMakingResult.taps.last?.timestamp else { + continue + } + + let parsedResult = AssessmentResult( + testDateTime: Date(), + timeSpent: timestamp, + errorCnt: Int(trailMakingResult.numberOfErrors) + ) + return parsedResult + } + } + return nil +} + + +#if DEBUG +#Preview { + TrailMakingTaskView() + .environment(AssessmentResults()) +} +#endif diff --git a/PICS/Assessment/TrailMakingTest.swift b/PICS/Assessment/TrailMakingTest.swift deleted file mode 100644 index 185de30..0000000 --- a/PICS/Assessment/TrailMakingTest.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// This source file is part of the PICS based on the Stanford Spezi Template Application project -// -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT -// - -import ResearchKit -import ResearchKitSwiftUI -import SwiftUI - -struct TrailMakingTaskView: View { - // Use @AppStorage to store the selected dates - @AppStorage("trailMakingResults") private var tmStorageResults: [AssessmentResult] = [] - - @Environment(\.presentationMode) var presentationMode - - var body: some View { - ZStack { - Color(red: 242 / 255, green: 242 / 255, blue: 247 / 255) - .edgesIgnoringSafeArea(.all) - ORKOrderedTaskView( - tasks: createTrailMakingTask(), - tintColor: .accentColor, - shouldConfirmCancel: true, - result: handleTaskResult - ) - .navigationBarTitle("TRAIL_MAKING_TEST_TITLE") - } - } - - private func createTrailMakingTask() -> ORKOrderedTask { - let task = ORKOrderedTask.trailmakingTask( - withIdentifier: "TrailMakingTask", - intendedUseDescription: "Tests visual attention and task switching", - trailmakingInstruction: nil, - trailType: .B, - options: [] - ) - return task - } - - // Handle task result - private func handleTaskResult(result: TaskResult) async { - // Close the test by dismissing the view - DispatchQueue.main.async { - self.presentationMode.wrappedValue.dismiss() - } - - guard case let .completed(taskResult) = result else { - return - } - let parsedResult = parseTMResult(taskResult: taskResult) - if let nonEmptyResult = parsedResult { - tmStorageResults += [nonEmptyResult] - } - } -} - -func parseTMResult(taskResult: ORKTaskResult) -> AssessmentResult? { - // Go to the trail making results and parse the result. - for result in taskResult.results ?? [] { - if let stepResult = result as? ORKStepResult { - if stepResult.identifier != "trailmaking" { - continue - } - for trailMakingResult in stepResult.results ?? [] { - if let curResult = trailMakingResult as? ORKTrailmakingResult { - let timeTask = if let lastItem = curResult.taps.last { - lastItem.timestamp - } else { - -1.0 - } - let parsedResult = AssessmentResult( - testDateTime: Date(), - timeSpent: timeTask, - errorCnt: Int(curResult.numberOfErrors) - ) - return parsedResult - } - } - } - } - return nil -} - -#Preview { - TrailMakingTaskView() -} diff --git a/PICS/Assessment/Views/AssessmentNotCompletedView.swift b/PICS/Assessment/Views/AssessmentNotCompletedView.swift new file mode 100644 index 0000000..7543d23 --- /dev/null +++ b/PICS/Assessment/Views/AssessmentNotCompletedView.swift @@ -0,0 +1,51 @@ +// +// This source file is part of the PICS based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct AssessmentNotCompletedView: View { + private let task: AssessmentTask + + + var body: some View { + VStack(alignment: .leading) { + HStack { + // Displays a warning icon and a message stating the test has not been completed. + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.accentColor) + .accessibilityHidden(true) + Text("\(task.testName): Not Completed") + .foregroundColor(.primary) + .lineLimit(1) + .minimumScaleFactor(0.5) + } + .font(.title3) + .bold() + .padding(.bottom, 5) + Text(task.testDescription) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + .font(.subheadline) + .padding(.bottom, 5) + } + .multilineTextAlignment(.center) + } + + + init(task: AssessmentTask) { + self.task = task + } +} + + +#if DEBUG +#Preview { + AssessmentNotCompletedView(task: .trailMaking) +} +#endif diff --git a/PICS/Assessment/Views/AssessmentTaskSection.swift b/PICS/Assessment/Views/AssessmentTaskSection.swift new file mode 100644 index 0000000..b65f424 --- /dev/null +++ b/PICS/Assessment/Views/AssessmentTaskSection.swift @@ -0,0 +1,65 @@ +// +// This source file is part of the PICS based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct AssessmentTaskSection: View { + private let task: AssessmentTask + + @Environment(AssessmentResults.self) + private var results + + @Binding private var presentingTask: AssessmentTask? + + + var body: some View { + Section { + VStack { + if results[keyPath: task.resultsKey].isEmpty { + AssessmentNotCompletedView(task: task) + } else { + ResultsViz(task: task, data: results[keyPath: task.resultsKey]) + } + + Divider() + .padding(.bottom, 5) + + Button(action: { + presentingTask = task + }) { + if results[keyPath: task.resultsKey].isEmpty { + Text("Start Assessment") + } else { + Text("Retake Assessment") + } + } + .multilineTextAlignment(.center) + .foregroundStyle(.accent) + .accessibilityIdentifier(task.accessibilityIdentifier) + .buttonStyle(.plain) // Use style to restrict clickable area. + } + } + } + + + init(task: AssessmentTask, presentingTask: Binding) { + self.task = task + self._presentingTask = presentingTask + } +} + + +#if DEBUG +#Preview { + List { + AssessmentTaskSection(task: .trailMaking, presentingTask: .constant(nil)) + } + .environment(AssessmentResults()) +} +#endif diff --git a/PICS/Assessment/Views/ResultsViz.swift b/PICS/Assessment/Views/ResultsViz.swift new file mode 100644 index 0000000..632a93c --- /dev/null +++ b/PICS/Assessment/Views/ResultsViz.swift @@ -0,0 +1,232 @@ +// +// This source file is part of the PICS based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Charts +import SwiftUI + +struct ResultsViz: View { + private let task: AssessmentTask + private let data: [AssessmentResult] + + @Environment(\.locale) + private var locale + + private let hasErrorCountMetrics: Bool + + let metricColor = Color.purple + let timeColor = Color.teal + + // Vars to show detailed results. + @State private var selectedElement: AssessmentResult? + + var body: some View { + Text(task.resultsTitle) + .font(.title3) + .bold() + .padding(5) + + // Helper text to show data when clicked or for the last result. + let details = getResultDetails() + if !details.isEmpty { + Text(details) + .font(.footnote) + .frame(maxWidth: .infinity, alignment: .leading) + .listRowSeparator(.hidden) + } + + chart + } + + private var chart: some View { + Chart { + ForEach(data) { dataPoint in + if let errorCnt = dataPoint.errorCnt { + LineMark( + x: .value("Time", dataPoint.testDateTime, unit: .second), + y: .value("Error Count", errorCnt), + series: .value("Measurement", String(localized: "Error Count", locale: locale)) + ) + .foregroundStyle(self.selectedElement == nil ? .purple : .gray) + .symbol { + lineMarkSymbol(data: dataPoint, strokeColor: .purple) + } + } + + LineMark( + x: .value("Time", dataPoint.testDateTime, unit: .second), + y: .value("ASSESSMENT_VIZ_TIME", dataPoint.timeSpent), + series: .value("Measurement", String(localized: "ASSESSMENT_VIZ_TIME", locale: locale)) + ) + .foregroundStyle(self.selectedElement == nil ? .teal : .gray) + .symbol { + lineMarkSymbol(data: dataPoint, strokeColor: .teal) + } + } + .lineStyle(StrokeStyle(lineWidth: 2.0)) + } + .chartForegroundStyleScale( + self.hasErrorCountMetrics + ? [ + String(localized: "ASSESSMENT_VIZ_TIME", locale: locale): self.timeColor, + String(localized: "Error Count", locale: locale): self.metricColor + ] + : [String(localized: "ASSESSMENT_VIZ_TIME", locale: locale): self.timeColor] + ) + .padding(10) + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 3)) + } + // Use overlay to detect user's selection on marks. + .chartOverlay { proxy in + GeometryReader { geo in + // The rectangle to detect user's clicked position and bar. + Rectangle() + .fill(.clear) + .contentShape(Rectangle()) + .gesture( + SpatialTapGesture() + .onEnded { value in + let element = findElement(location: value.location, proxy: proxy, geometry: geo) + if selectedElement?.testDateTime == element?.testDateTime { + // If tapping the same element, clear the selection. + selectedElement = nil + } else { + selectedElement = element + } + } + .exclusively( + before: DragGesture() + .onChanged { value in + selectedElement = findElement(location: value.location, proxy: proxy, geometry: geo) + } + ) + ) + } + } + } + + init(task: AssessmentTask, data: [AssessmentResult]) { + self.task = task + self.data = data + self.hasErrorCountMetrics = !data.contains { $0.errorCnt != nil } + } + + + @ViewBuilder + private func lineMarkSymbol(data: AssessmentResult, strokeColor: Color) -> some View { + let isSelectedPoint = selectedElement?.testDateTime == data.testDateTime + if isSelectedPoint { + Circle() + .strokeBorder(strokeColor, lineWidth: 2) + .background(Circle().foregroundColor(strokeColor)) + .frame(width: 10) + } else { + Circle() + .strokeBorder(selectedElement == nil ? strokeColor : .gray, lineWidth: 2) + .frame(width: 5) + } + } + + private func findElement(location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> AssessmentResult? { + guard let proxyF = proxy.plotFrame else { + print("Failed to get proxy plotframe") + return nil + } + let relativeXPosition = location.x - geometry[proxyF].origin.x + if let date = proxy.value(atX: relativeXPosition) as Date? { + // Find the closest date element. + var minDistance: TimeInterval = .infinity + var index: Int? + for dataIndex in data.indices { + let nthDataDistance = data[dataIndex].testDateTime.distance(to: date) + if abs(nthDataDistance) < minDistance { + minDistance = abs(nthDataDistance) + index = dataIndex + } + } + if let index { + return data[index] + } + } + return nil + } + + // Get the helper text for result to show. It should be + // the selected or the last result if available. + private func getResultDetails() -> String { + var prefix = "" + var elm: AssessmentResult + if let elmSel = self.selectedElement { + elm = elmSel + prefix = String(localized: "ASSESSMENT_SUMMARY", locale: locale) + } else if let elmLast = data.last { + elm = elmLast + prefix = String(localized: "ASSESSMENT_SUMMARY_LAST", locale: locale) + } else { + // No available element to show. + return "" + } + + // Round the time to 2 decimal points. + let timeSpent = round(elm.timeSpent * 10) / 10 + + let metricText = if let errorCnt = elm.errorCnt { + ", " + + String(localized: "Error Count", locale: locale) + + ": " + + String(errorCnt) + } else { + "" + } + return ( + prefix + + String(elm.testDateTime.formatted(.dateTime.year().month().day())) + + ":\n" + + String(localized: "ASSESSMENT_VIZ_TIME", locale: locale) + + ": " + + String(timeSpent) + + metricText + ) + } +} + + +#if DEBUG +#Preview { + ResultsViz(task: .trailMaking, data: [ + AssessmentResult( + testDateTime: .now.addingTimeInterval(-22), + timeSpent: 5 + ), + AssessmentResult( + testDateTime: .now.addingTimeInterval(-12), + timeSpent: 10, + errorCnt: 2 + ), + AssessmentResult( + testDateTime: .now, + timeSpent: 20, + errorCnt: 1 + ), + AssessmentResult( + testDateTime: .now.addingTimeInterval(5), + timeSpent: 17, + errorCnt: 0 + ) + ]) +} + +#Preview { + ResultsViz(task: .trailMaking, data: [ + AssessmentResult( + testDateTime: .now.addingTimeInterval(5), + timeSpent: 17 + ) + ]) +} +#endif diff --git a/PICS/HealthVisulization/HKVisualization.swift b/PICS/HealthVisulization/HKVisualization.swift index e4f3f4d..406c325 100644 --- a/PICS/HealthVisulization/HKVisualization.swift +++ b/PICS/HealthVisulization/HKVisualization.swift @@ -36,10 +36,9 @@ struct HKVisualization: View { data: self.stepData, xName: "Time", yName: "Step Count", - title: String(localized: "HKVIZ_PLOT_STEP_TITLE"), - chartType: "bars", + title: "HKVIZ_PLOT_STEP_TITLE", threshold: 5000.0, - helperText: String(localized: "HKVIZ_PLOT_STEP_RECOMD") + helperText: "HKVIZ_PLOT_STEP_RECOMD" ) } Section { @@ -47,8 +46,7 @@ struct HKVisualization: View { data: self.heartRateData, xName: "Time", yName: "Heart Rate Per Minute", - title: String(localized: "HKVIZ_PLOT_HEART_TITLE"), - chartType: "maxMin", + title: "HKVIZ_PLOT_HEART_TITLE", scatterData: self.heartRateScatterData ) } @@ -57,10 +55,9 @@ struct HKVisualization: View { data: self.oxygenSaturationData, xName: "Time", yName: "Oxygen Saturation (precent)", - title: String(localized: "HKVIZ_PLOT_OXYGEN_TITLE"), - chartType: "maxMin", + title: "HKVIZ_PLOT_OXYGEN_TITLE", threshold: 94.0, - helperText: String(localized: "HKVIZ_PLOT_OXYGEN_RECOMD"), + helperText: "HKVIZ_PLOT_OXYGEN_RECOMD", scatterData: self.oxygenSaturationScatterData ) } @@ -74,7 +71,7 @@ struct HKVisualization: View { return NavigationStack { visualizationList - .navigationTitle(String(localized: "HKVIZ_NAVIGATION_TITLE")) + .navigationTitle("HKVIZ_NAVIGATION_TITLE") .toolbar { if AccountButton.shouldDisplay { AccountButton(isPresented: $presentingAccount) diff --git a/PICS/HealthVisulization/HKVisualizationItem.swift b/PICS/HealthVisulization/HKVisualizationItem.swift index cffc3aa..f4113d9 100644 --- a/PICS/HealthVisulization/HKVisualizationItem.swift +++ b/PICS/HealthVisulization/HKVisualizationItem.swift @@ -13,25 +13,24 @@ import SwiftUI struct HKVisualizationItem: View { - // Environment recording light or dark mode to decide color. - @Environment(\.colorScheme) var colorScheme - let id = UUID() - var data: [HKData] - var xName: String - var yName: String - var title: String - var chartType: String - var ymin: Double - var ymax: Double - var threshold: Double = -1.0 - var helperText = "" - var plotAvg = false - var scatterData: [HKData] = [] - + let data: [HKData] + let xName: LocalizedStringResource + let yName: LocalizedStringResource + let title: LocalizedStringResource + let ymin: Double + let ymax: Double + let threshold: Double? + let helperText: LocalizedStringResource? + let plotAvg: Bool + let scatterData: [HKData] + // Variables for lollipops. let lollipopColor: Color = .indigo + @Environment(\.locale) + private var locale + @State private var selectedElement: HKData? var body: some View { @@ -39,24 +38,24 @@ struct HKVisualizationItem: View { .font(.title3.bold()) // Remove line below text. .listRowSeparator(.hidden) - if !self.helperText.isEmpty { - Text(self.helperText) + if let helperText { + Text(helperText) .font(.caption) .listRowSeparator(.hidden) } // Helper text to show data when clicked. if let elm = selectedElement, elm.sumValue == 0 { let details = ( - String(localized: "HKVIZ_SUMMARY") + + String(localized: "HKVIZ_SUMMARY", locale: locale) + String(elm.date.formatted(.dateTime.year().month().day())) + ":\n" + - String(localized: "HKVIZ_AVERAGE_STRING") + + String(localized: "HKVIZ_AVERAGE_STRING", locale: locale) + String(round(elm.avgValue * 10) / 10) + ", " + - String(localized: "HKVIZ_MAX_STRING") + + String(localized: "HKVIZ_MAX_STRING", locale: locale) + String(Int(round(elm.maxValue))) + ", " + - String(localized: "HKVIZ_MIN_STRING") + + String(localized: "HKVIZ_MIN_STRING", locale: locale) + String(Int(round(elm.minValue))) ) Text(details) @@ -70,32 +69,32 @@ struct HKVisualizationItem: View { Chart { ForEach(scatterData) { dataPoint in PointMark( - x: .value(self.xName, dataPoint.date, unit: .day), - y: .value(self.yName, dataPoint.sumValue) + x: .value(.init(self.xName), dataPoint.date, unit: .day), + y: .value(.init(self.yName), dataPoint.sumValue) ) .foregroundStyle(getBarColor(value: dataPoint.sumValue, date: dataPoint.date).opacity(0.2)) } ForEach(data) { dataPoint in BarMark( - x: .value(self.xName, dataPoint.date, unit: .day), - y: .value(self.yName, dataPoint.sumValue), + x: .value(.init(self.xName), dataPoint.date, unit: .day), + y: .value(.init(self.yName), dataPoint.sumValue), width: .fixed(10) ) - .foregroundStyle(getBarColor(value: dataPoint.sumValue, date: dataPoint.date)) + .foregroundStyle(getBarColor(value: dataPoint.sumValue, date: dataPoint.date)) if self.plotAvg { LineMark( - x: .value(self.xName, dataPoint.date, unit: .day), - y: .value(self.yName, dataPoint.avgValue) + x: .value(.init(self.xName), dataPoint.date, unit: .day), + y: .value(.init(self.yName), dataPoint.avgValue) ) .foregroundStyle(selectedElement != nil ? Color.gray : Color.purple) .lineStyle(StrokeStyle(lineWidth: 2)) } } - if self.threshold > 0 { + if let threshold { RuleMark( - y: .value("Threshold", self.threshold) + y: .value("Threshold", threshold) ) - .foregroundStyle(colorScheme == .dark ? .white : .black) + .foregroundStyle(.primary) .lineStyle(StrokeStyle(lineWidth: 1, dash: [5])) } } @@ -148,25 +147,24 @@ struct HKVisualizationItem: View { init( data: [HKData], - xName: String, - yName: String, - title: String, - chartType: String, + xName: LocalizedStringResource, + yName: LocalizedStringResource, + title: LocalizedStringResource, threshold: Double = -1.0, - helperText: String = "", + helperText: LocalizedStringResource? = nil, scatterData: [HKData] = [] ) { self.data = data self.xName = xName self.yName = yName self.title = title - self.chartType = chartType self.threshold = threshold self.helperText = helperText + // Find max and min for y range. - self.ymax = data.map(\.maxValue).max() ?? 0 + let ymax = data.map(\.maxValue).max() ?? 0 // For step data, we only have sum values. - self.ymax = max(self.ymax, data.map(\.sumValue).max() ?? 0) + self.ymax = max(ymax, data.map(\.sumValue).max() ?? 0) self.ymin = data.map(\.minValue).min() ?? 0 // Plot average data if we have such data. self.plotAvg = data.map(\.avgValue).max() ?? 0 > 0 @@ -182,7 +180,7 @@ struct HKVisualizationItem: View { } else { .gray } - } else if self.threshold > 0 && value > self.threshold { + } else if let threshold, value > threshold { .blue } else { .accentColor @@ -244,7 +242,6 @@ struct HKVisualizationItem: View { data: [], xName: "Preview x axis", yName: "Preview y axis", - title: "Preview Title", - chartType: "PointMark" + title: "Preview Title" ) } diff --git a/PICS/Helper/CodableArray+RawRepresentable.swift b/PICS/Helper/CodableArray+RawRepresentable.swift index 9321777..77ee683 100644 --- a/PICS/Helper/CodableArray+RawRepresentable.swift +++ b/PICS/Helper/CodableArray+RawRepresentable.swift @@ -7,22 +7,52 @@ // import Foundation +import OSLog -extension Array: RawRepresentable where Element: Codable { +protocol CodableRawRepresentable: RawRepresentable, Codable where RawValue == String { + var defaultJson: String { get } +} + +extension CodableRawRepresentable { + private static var logger: Logger { + Logger(subsystem: "de.charite.pics", category: "AppStorage") + } + + /// Encode via JSON Encoder public var rawValue: String { - guard let data = try? JSONEncoder().encode(self), - let rawValue = String(data: data, encoding: .utf8) else { - return "[]" + let data: Data + do { + data = try JSONEncoder().encode(self) + } catch { + Self.logger.error("Failed to encode \(Self.self): \(error)") + return defaultJson + } + + guard let rawValue = String(data: data, encoding: .utf8) else { + return defaultJson } return rawValue } - + + /// Decode via JSON Decoder public init?(rawValue: String) { - guard let data = rawValue.data(using: .utf8), - let result = try? JSONDecoder().decode([Element].self, from: data) else { + guard let data = rawValue.data(using: .utf8) else { return nil } - self = result + + do { + self = try JSONDecoder().decode(Self.self, from: data) + } catch { + Self.logger.error("Failed to decode \(Self.self): \(error)") + return nil + } + } +} + + +extension Array: CodableRawRepresentable, RawRepresentable where Element: Codable { + var defaultJson: String { + "[]" } } diff --git a/PICS/Home.swift b/PICS/Home.swift index 46cc8b9..a052ee3 100644 --- a/PICS/Home.swift +++ b/PICS/Home.swift @@ -7,7 +7,6 @@ // import SpeziAccount -import SpeziMockWebService import SwiftUI @@ -25,10 +24,13 @@ struct HomeView: View { !FeatureFlags.disableFirebase && !FeatureFlags.skipOnboarding } + @Environment(PatientInformation.self) + private var patientInformation + + @AppStorage(StorageKeys.homeTabSelection) + private var selectedTab = Tabs.appointments - @AppStorage(StorageKeys.homeTabSelection) private var selectedTab = Tabs.appointments @State private var presentingAccount = false - @Environment(AppointmentInformation.self) private var appointmentInfo var body: some View { @@ -58,13 +60,6 @@ struct HomeView: View { .tabItem { Label("CONTACTS_TAB_TITLE", systemImage: "person.fill") } - if FeatureFlags.disableFirebase { - MockUpload(presentingAccount: $presentingAccount) - .tag(Tabs.mockUpload) - .tabItem { - Label("MOCK_WEB_SERVICE_TAB_TITLE", systemImage: "server.rack") - } - } } .sheet(isPresented: $presentingAccount) { AccountSheet() @@ -86,21 +81,7 @@ struct HomeView: View { return HomeView() .previewWith(standard: PICSStandard()) { PICSScheduler() - MockWebService() AccountConfiguration(building: details, active: MockUserIdPasswordAccountService()) } } - -#Preview { - CommandLine.arguments.append("--disableFirebase") // make sure the MockWebService is displayed - return HomeView() - .environment(AppointmentInformation()) - .previewWith(standard: PICSStandard()) { - PICSScheduler() - MockWebService() - AccountConfiguration { - MockUserIdPasswordAccountService() - } - } -} #endif diff --git a/PICS/MockUpload/MockUpload.swift b/PICS/MockUpload/MockUpload.swift deleted file mode 100644 index 9634f74..0000000 --- a/PICS/MockUpload/MockUpload.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// This source file is part of the PICS based on the Stanford Spezi Template Application project -// -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT -// - -import SpeziMockWebService -import SwiftUI - - -struct MockUpload: View { - @Binding var presentingAccount: Bool - - var body: some View { - NavigationStack { - RequestList() - .toolbar { - if AccountButton.shouldDisplay { - AccountButton(isPresented: $presentingAccount) - } - } - } - } - - - init(presentingAccount: Binding) { - self._presentingAccount = presentingAccount - } -} - - -#if DEBUG -#Preview { - MockUpload(presentingAccount: .constant(false)) - .previewWith { - MockWebService() - } -} -#endif diff --git a/PICS/Onboarding/AccountOnboarding.swift b/PICS/Onboarding/AccountOnboarding.swift index f5f9d20..ab6203d 100644 --- a/PICS/Onboarding/AccountOnboarding.swift +++ b/PICS/Onboarding/AccountOnboarding.swift @@ -12,9 +12,11 @@ import SwiftUI struct AccountOnboarding: View { - @Environment(Account.self) private var account - @Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath - + @Environment(Account.self) + private var account + @Environment(OnboardingNavigationPath.self) + private var onboardingNavigationPath + var body: some View { AccountSetup { _ in diff --git a/PICS/Onboarding/AccountQuestionnaire.swift b/PICS/Onboarding/AccountQuestionnaire.swift index 389ba1d..3dbb624 100644 --- a/PICS/Onboarding/AccountQuestionnaire.swift +++ b/PICS/Onboarding/AccountQuestionnaire.swift @@ -12,8 +12,9 @@ import SwiftUI struct AccountQuestionnaire: View { - @Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath - @AppStorage("isSurveyCompleted") var isSurveyCompleted = false + @Environment(OnboardingNavigationPath.self) + private var onboardingNavigationPath + @State var isSheetPresented = false @State private var showOnboardingQuestionnaire = false @@ -38,27 +39,22 @@ struct AccountQuestionnaire: View { }, actionView: { VStack { OnboardingActionsView( - "Take Questionnaire", - action: { + primaryText: "Take Questionnaire", + primaryAction: { isSheetPresented = true - } - ) - .padding(.vertical, 16) - OnboardingActionsView( - "Skip", - action: { - onboardingNavigationPath.nextStep() - } - ) - .sheet(isPresented: $isSheetPresented) { - OnboardingQuestionnaire(isSheetPresented: $isSheetPresented) + }, + secondaryText: "Skip" + ) { + onboardingNavigationPath.nextStep() } } } ) - // .navigationBarBackButtonHidden(healthKitProcessing) - // Small fix as otherwise "Login" or "Sign up" is still shown in the nav bar - .navigationTitle(Text(verbatim: "")) + .sheet(isPresented: $isSheetPresented) { + PersonalInformationQuestionnaire { + onboardingNavigationPath.nextStep() + } + } } } @@ -67,7 +63,9 @@ struct AccountQuestionnaire: View { #Preview { OnboardingStack { AccountQuestionnaire() + Text(verbatim: "Next Page") } .previewWith(standard: PICSStandard()) {} + .environment(PatientInformation()) } #endif diff --git a/PICS/Onboarding/ApptInfo.swift b/PICS/Onboarding/ApptInfo.swift index b9a2489..864d382 100644 --- a/PICS/Onboarding/ApptInfo.swift +++ b/PICS/Onboarding/ApptInfo.swift @@ -19,8 +19,10 @@ struct DateTimePickerView: View { struct ApptInfo: View { - @Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath - @Environment(AppointmentInformation.self) private var appointmentInfo + @Environment(OnboardingNavigationPath.self) + private var onboardingNavigationPath + @Environment(PatientInformation.self) + private var patientInformation @State private var appt0 = Date() @State private var appt1 = Date() @@ -33,22 +35,22 @@ struct ApptInfo: View { }, contentView: { VStack { - Text(String(localized: "APPTQ_0")) - .font(.headline) + Text("APPTQ_0") + .font(.headline) DateTimePickerView(selectedDateTime: $appt0) .padding(.bottom, 32) - Text(String(localized: "APPTQ_1")) - .font(.headline) + Text("APPTQ_1") + .font(.headline) DateTimePickerView(selectedDateTime: $appt1) .padding(.bottom, 32) - Text(String(localized: "APPTQ_2")) - .font(.headline) + Text("APPTQ_2") + .font(.headline) DateTimePickerView(selectedDateTime: $appt2) } }, actionView: { OnboardingActionsView("INTERESTING_MODULES_BUTTON") { - appointmentInfo.storeDates(appt0, appt1, appt2) + patientInformation.storeDates(appt0, appt1, appt2) onboardingNavigationPath.nextStep() } @@ -64,6 +66,6 @@ struct ApptInfo: View { OnboardingStack { ApptInfo() } - .environment(AppointmentInformation()) + .environment(PatientInformation()) } #endif diff --git a/PICS/Onboarding/Consent.swift b/PICS/Onboarding/Consent.swift index 1eb9b55..5b8f879 100644 --- a/PICS/Onboarding/Consent.swift +++ b/PICS/Onboarding/Consent.swift @@ -12,8 +12,9 @@ import SwiftUI /// - Note: The `OnboardingConsentView` exports the signed consent form as PDF to the Spezi `Standard`, necessitating the conformance of the `Standard` to the `OnboardingConstraint`. struct Consent: View { - @Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath - + @Environment(OnboardingNavigationPath.self) + private var onboardingNavigationPath + private var consentDocument: Data { guard let path = Bundle.main.url(forResource: "ConsentDocument", withExtension: "md"), diff --git a/PICS/Onboarding/ContentView.swift b/PICS/Onboarding/ContentView.swift deleted file mode 100644 index f696461..0000000 --- a/PICS/Onboarding/ContentView.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// This source file is part of the ImageSource open source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// -import Foundation -import ImageSource -import PDFKit -import SwiftUI - - -struct ContentView: View { - @Binding var image: UIImage? - @Environment(PICSStandard.self) private var standard - - private var swiftUIImage: some View { - image.flatMap { - Image(uiImage: $0) - .accessibilityLabel(Text("Medication Plan")) - } - } - - var body: some View { - ImageSource(image: $image) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .padding() - .onChange(of: image) { - uploadImage(image: image) - } - } - func uploadImage(image: UIImage?) { - if let img = image { - if let pdfPage = PDFPage(image: img) { - let pdfDocument = PDFDocument() - pdfDocument.insert(pdfPage, at: 0) - Task { - await standard.storeImage(image: pdfDocument) - } - } else { - print("Failed to create PDFPage from image") - } - } - } -} - - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView(image: .constant(nil)) - } -} diff --git a/PICS/Onboarding/InterestingModules.swift b/PICS/Onboarding/Information/InterestingModules.swift similarity index 94% rename from PICS/Onboarding/InterestingModules.swift rename to PICS/Onboarding/Information/InterestingModules.swift index 9e757e2..1031372 100644 --- a/PICS/Onboarding/InterestingModules.swift +++ b/PICS/Onboarding/Information/InterestingModules.swift @@ -11,8 +11,9 @@ import SwiftUI struct InterestingModules: View { - @Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath - + @Environment(OnboardingNavigationPath.self) + private var onboardingNavigationPath + var body: some View { SequentialOnboardingView( diff --git a/PICS/Onboarding/Welcome.swift b/PICS/Onboarding/Information/Welcome.swift similarity index 95% rename from PICS/Onboarding/Welcome.swift rename to PICS/Onboarding/Information/Welcome.swift index 6e5bf08..0b83c1d 100644 --- a/PICS/Onboarding/Welcome.swift +++ b/PICS/Onboarding/Information/Welcome.swift @@ -11,8 +11,9 @@ import SwiftUI struct Welcome: View { - @Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath - + @Environment(OnboardingNavigationPath.self) + private var onboardingNavigationPath + var body: some View { OnboardingView( diff --git a/PICS/Onboarding/Medication/ImageCanvas.swift b/PICS/Onboarding/Medication/ImageCanvas.swift new file mode 100644 index 0000000..e3a5ded --- /dev/null +++ b/PICS/Onboarding/Medication/ImageCanvas.swift @@ -0,0 +1,61 @@ +// +// This source file is part of the ImageSource open source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import ImageSource +import PDFKit +import SwiftUI + + +struct ImageCanvas: View { + @Environment(PICSStandard.self) + private var standard + + @Binding var image: UIImage? + + var body: some View { + ImageSource(image: $image) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding() + .onChange(of: image) { + guard let image else { + return + } + uploadImage(image: image) + } + } + + + func uploadImage(image: UIImage) { + if let pdfPage = PDFPage(image: image) { + let pdfDocument = PDFDocument() + pdfDocument.insert(pdfPage, at: 0) + Task { + await standard.storeImage(image: pdfDocument) + } + } else { + print("Failed to create PDFPage from image") + } + } +} + + +#if DEBUG +#Preview { + struct ImagePreview: View { + @State var image: UIImage? + + var body: some View { + ImageCanvas(image: $image) + } + } + + return ImagePreview() + .previewWith(standard: PICSStandard()) {} +} +#endif diff --git a/PICS/Onboarding/Medication.swift b/PICS/Onboarding/Medication/Medication.swift similarity index 72% rename from PICS/Onboarding/Medication.swift rename to PICS/Onboarding/Medication/Medication.swift index c2d5001..26bb64b 100644 --- a/PICS/Onboarding/Medication.swift +++ b/PICS/Onboarding/Medication/Medication.swift @@ -12,8 +12,11 @@ import SwiftUI struct Medication: View { - @Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath + @Environment(OnboardingNavigationPath.self) + private var onboardingNavigationPath @State var image: UIImage? + + var body: some View { OnboardingView( contentView: { @@ -23,7 +26,7 @@ struct Medication: View { subtitle: "MEDICATION_SUBTITLE" ) Spacer() - ContentView(image: $image) + ImageCanvas(image: $image) } }, actionView: { OnboardingActionsView( @@ -32,12 +35,9 @@ struct Medication: View { onboardingNavigationPath.nextStep() } ) - .disabled(image == nil) + .disabled(image == nil) } ) - - // Small fix as otherwise "Login" or "Sign up" is still shown in the nav bar - .navigationTitle(Text(verbatim: "")) } } @@ -47,7 +47,6 @@ struct Medication: View { OnboardingStack { Medication() } - .previewWith(standard: PICSStandard()) { - } + .previewWith(standard: PICSStandard()) {} } #endif diff --git a/PICS/Onboarding/OnboardingFlow.swift b/PICS/Onboarding/OnboardingFlow.swift index 7ce77f3..6ff1816 100644 --- a/PICS/Onboarding/OnboardingFlow.swift +++ b/PICS/Onboarding/OnboardingFlow.swift @@ -15,11 +15,14 @@ import SwiftUI /// Displays an multi-step onboarding flow for the PICS. struct OnboardingFlow: View { - @Environment(HealthKit.self) private var healthKitDataSource - @Environment(PICSScheduler.self) private var scheduler + @Environment(HealthKit.self) + private var healthKitDataSource + @Environment(PICSScheduler.self) + private var scheduler + + @AppStorage(StorageKeys.onboardingFlowComplete) + private var completedOnboardingFlow = false - @AppStorage(StorageKeys.onboardingFlowComplete) private var completedOnboardingFlow = false - @State private var localNotificationAuthorization = false private var healthKitAuthorization: Bool { diff --git a/PICS/Onboarding/OnboardingQuestionnaire.swift b/PICS/Onboarding/OnboardingQuestionnaire.swift deleted file mode 100644 index 8b29337..0000000 --- a/PICS/Onboarding/OnboardingQuestionnaire.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// This source file is part of the PICS based on the Stanford Spezi Template Application project -// -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT -// -import SpeziOnboarding -import SpeziQuestionnaire -import SwiftUI -struct OnboardingQuestionnaire: View { - @Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath - @Environment(PICSStandard.self) private var standard - @AppStorage("isSurveyCompleted") var isSurveyCompleted = false - @Binding var isSheetPresented: Bool - - var body: some View { - QuestionnaireView( - questionnaire: Bundle.main.questionnaire(withName: "Onboarding-Questionnaire") - ) { result in - guard case let .completed(response) = result else { - isSheetPresented = false - onboardingNavigationPath.nextStep() - return // user cancelled - } - isSurveyCompleted = true - isSheetPresented = false - await standard.add(response: response) - onboardingNavigationPath.nextStep() - } - } -} - -#Preview { - OnboardingStack { - OnboardingQuestionnaire(isSheetPresented: .constant(true)) - } - .previewWith(standard: PICSStandard()) {} -} diff --git a/PICS/Onboarding/HealthKitPermissions.swift b/PICS/Onboarding/Permissions/HealthKitPermissions.swift similarity index 88% rename from PICS/Onboarding/HealthKitPermissions.swift rename to PICS/Onboarding/Permissions/HealthKitPermissions.swift index b365499..c3db58a 100644 --- a/PICS/Onboarding/HealthKitPermissions.swift +++ b/PICS/Onboarding/Permissions/HealthKitPermissions.swift @@ -12,9 +12,11 @@ import SwiftUI struct HealthKitPermissions: View { - @Environment(HealthKit.self) private var healthKitDataSource - @Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath - + @Environment(HealthKit.self) + private var healthKitDataSource + @Environment(OnboardingNavigationPath.self) + private var onboardingNavigationPath + @State private var healthKitProcessing = false @@ -59,8 +61,6 @@ struct HealthKitPermissions: View { } ) .navigationBarBackButtonHidden(healthKitProcessing) - // Small fix as otherwise "Login" or "Sign up" is still shown in the nav bar - .navigationTitle(Text(verbatim: "")) } } diff --git a/PICS/Onboarding/NotificationPermissions.swift b/PICS/Onboarding/Permissions/NotificationPermissions.swift similarity index 89% rename from PICS/Onboarding/NotificationPermissions.swift rename to PICS/Onboarding/Permissions/NotificationPermissions.swift index 2254152..fa3adaf 100644 --- a/PICS/Onboarding/NotificationPermissions.swift +++ b/PICS/Onboarding/Permissions/NotificationPermissions.swift @@ -12,9 +12,11 @@ import SwiftUI struct NotificationPermissions: View { - @Environment(PICSScheduler.self) private var scheduler - @Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath - + @Environment(PICSScheduler.self) + private var scheduler + @Environment(OnboardingNavigationPath.self) + private var onboardingNavigationPath + @State private var notificationProcessing = false @@ -58,8 +60,6 @@ struct NotificationPermissions: View { } ) .navigationBarBackButtonHidden(notificationProcessing) - // Small fix as otherwise "Login" or "Sign up" is still shown in the nav bar - .navigationTitle(Text(verbatim: "")) } } diff --git a/PICS/PICS.swift b/PICS/PICS.swift index 310e4c1..a1c1965 100644 --- a/PICS/PICS.swift +++ b/PICS/PICS.swift @@ -13,9 +13,12 @@ import SwiftUI @main struct PICS: App { - @UIApplicationDelegateAdaptor(PICSDelegate.self) var appDelegate - @AppStorage(StorageKeys.onboardingFlowComplete) var completedOnboardingFlow = false - @State var apptInfo = AppointmentInformation() + @UIApplicationDelegateAdaptor(PICSDelegate.self) + private var appDelegate + @AppStorage(StorageKeys.onboardingFlowComplete) + private var completedOnboardingFlow = false + + @State var patientInformation = PatientInformation() var body: some Scene { @@ -32,7 +35,7 @@ struct PICS: App { } .testingSetup() .spezi(appDelegate) - .environment(apptInfo) + .environment(patientInformation) } } } diff --git a/PICS/PICSDelegate.swift b/PICS/PICSDelegate.swift index 99aee99..67939e7 100644 --- a/PICS/PICSDelegate.swift +++ b/PICS/PICSDelegate.swift @@ -12,32 +12,18 @@ import SpeziFirebaseAccount import SpeziFirebaseStorage import SpeziFirestore import SpeziHealthKit -import SpeziMockWebService import SpeziOnboarding import SpeziScheduler import SwiftUI class PICSDelegate: SpeziAppDelegate { - // The newer Swift versions does not have a default property of window, - // which will cause the NSInvalidArgumentException error for Stroop - // assessment test. Such errors should be fixed in newer version of - // ResearchKit but it might not be updated in the StanfordBDHG forked - // version that we use. Therefore, we manually add window here to walk - // around the error currently. - var window: UIWindow? - override var configuration: Configuration { Configuration(standard: PICSStandard()) { if !FeatureFlags.disableFirebase { AccountConfiguration(configuration: [ .requires(\.userId), - .requires(\.name), - // additional values stored using the `FirestoreAccountStorage` within our Standard implementation - .collects(\.dateOfBirth), - .collects(\.genderIdentity), - .collects(\.height), - .collects(\.weight) + .requires(\.name) ]) if FeatureFlags.useFirebaseEmulator { @@ -54,8 +40,6 @@ class PICSDelegate: SpeziAppDelegate { } else { FirebaseStorageConfiguration() } - } else { - MockWebService() } if HKHealthStore.isHealthDataAvailable() { @@ -106,17 +90,17 @@ class PICSDelegate: SpeziAppDelegate { CollectSample( HKQuantityType(.stepCount), predicate: predicateThreeMonth, - deliverySetting: .background(.afterAuthorizationAndApplicationWillLaunch) + deliverySetting: .background(.automatic) ) CollectSample( HKQuantityType(.heartRate), predicate: predicateThreeMonth, - deliverySetting: .background(.afterAuthorizationAndApplicationWillLaunch) + deliverySetting: .background(.automatic) ) CollectSample( HKQuantityType(.oxygenSaturation), predicate: predicateThreeMonth, - deliverySetting: .background(.afterAuthorizationAndApplicationWillLaunch) + deliverySetting: .background(.automatic) ) } } diff --git a/PICS/PICSStandard.swift b/PICS/PICSStandard.swift index fe14cf3..2ed96c3 100644 --- a/PICS/PICSStandard.swift +++ b/PICS/PICSStandard.swift @@ -16,7 +16,6 @@ import SpeziAccount import SpeziFirebaseAccountStorage import SpeziFirestore import SpeziHealthKit -import SpeziMockWebService import SpeziOnboarding import SpeziQuestionnaire import SwiftUI @@ -38,7 +37,6 @@ actor PICSStandard: Standard, EnvironmentAccessible, HealthKitConstraint, Onboar Firestore.firestore().collection("users") } - @Dependency var mockWebService: MockWebService? @Dependency var accountStorage: FirestoreAccountStorage? @AccountReference var account: Account @@ -75,14 +73,10 @@ actor PICSStandard: Standard, EnvironmentAccessible, HealthKitConstraint, Onboar func add(sample: HKSample) async { - if let mockWebService { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] - let jsonRepresentation = (try? String(data: encoder.encode(sample.resource), encoding: .utf8)) ?? "" - try? await mockWebService.upload(path: "healthkit/\(sample.uuid.uuidString)", body: jsonRepresentation) + guard !FeatureFlags.disableFirebase else { return } - + await parseAndAddHkData(sample: sample) } @@ -124,11 +118,10 @@ actor PICSStandard: Standard, EnvironmentAccessible, HealthKitConstraint, Onboar } func remove(sample: HKDeletedObject) async { - if let mockWebService { - try? await mockWebService.remove(path: "healthkit/\(sample.uuid.uuidString)") + guard !FeatureFlags.disableFirebase else { return } - + do { try await healthKitDocument(id: sample.uuid).delete() } catch { @@ -139,9 +132,7 @@ actor PICSStandard: Standard, EnvironmentAccessible, HealthKitConstraint, Onboar func add(response: ModelsR4.QuestionnaireResponse) async { let id = response.identifier?.value?.value?.string ?? UUID().uuidString - if let mockWebService { - let jsonRepresentation = (try? String(data: JSONEncoder().encode(response), encoding: .utf8)) ?? "" - try? await mockWebService.upload(path: "questionnaireResponse/\(id)", body: jsonRepresentation) + guard !FeatureFlags.disableFirebase else { return } diff --git a/PICS/PICSTestingSetup.swift b/PICS/PICSTestingSetup.swift index e2c1d78..114451c 100644 --- a/PICS/PICSTestingSetup.swift +++ b/PICS/PICSTestingSetup.swift @@ -10,8 +10,9 @@ import SwiftUI private struct PICSAppTestingSetup: ViewModifier { - @AppStorage(StorageKeys.onboardingFlowComplete) var completedOnboardingFlow = false - + @AppStorage(StorageKeys.onboardingFlowComplete) + private var completedOnboardingFlow = false + func body(content: Content) -> some View { content diff --git a/PICS/Patient Information/HeightKey.swift b/PICS/Patient Information/HeightKey.swift deleted file mode 100644 index 7c875ac..0000000 --- a/PICS/Patient Information/HeightKey.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// This source file is part of the PICS based on the Stanford Spezi Template Application project -// -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT -// -import SpeziAccount -import SpeziFoundation -import SpeziValidation -import SpeziViews -import SwiftUI - -/// The height of a user. -public struct HeightKey: AccountKey { - public typealias Value = Int - public static let name = LocalizedStringResource("HEIGHT") - public static let category: AccountKeyCategory = .personalDetails -} - - -extension AccountKeys { - /// this is the HeightKey of the user - public var height: HeightKey.Type { - HeightKey.self - } -} - - -extension AccountValues { - /// this is the value of the height to store - public var height: Int? { - storage[HeightKey.self] - } -} - - -// MARK: - UI -extension HeightKey { - public struct DataDisplay: DataDisplayView { - public typealias Key = HeightKey - private let height: Int - public init(_ value: Int) { - self.height = value - } - public var body: some View { - HStack { - Text(HeightKey.name) - Spacer() - Text("\(height) cm") - .foregroundColor(.secondary) - } - .accessibilityElement(children: .combine) - } - } -} -extension HeightKey { - public struct DataEntry: DataEntryView { - public typealias Key = HeightKey - @Binding private var height: Int - public var body: some View { - HStack { - Text(HeightKey.name) - Spacer() - TextField("Height", value: $height, formatter: NumberFormatter()) - .frame(width: 120) // set frame width to enable more spaces. - } - } - public init(_ value: Binding) { - self._height = value - } - } -} diff --git a/PICS/Patient Information/WeightKey.swift b/PICS/Patient Information/WeightKey.swift deleted file mode 100644 index 62c6997..0000000 --- a/PICS/Patient Information/WeightKey.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// This source file is part of the PICS based on the Stanford Spezi Template Application project -// -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT -// -import SpeziAccount -import SpeziFoundation -import SpeziValidation -import SpeziViews -import SwiftUI - -/// The weight of a user. -public struct WeightKey: AccountKey { - public typealias Value = Int - public static let name = LocalizedStringResource("WEIGHT") - public static let category: AccountKeyCategory = .personalDetails -} - - -extension AccountKeys { - /// this is the weightKey of the user - public var weight: WeightKey.Type { - WeightKey.self - } -} - - -extension AccountValues { - /// this is the weight value to be stored - public var weight: Int? { - storage[WeightKey.self] - } -} - - -// MARK: - UI -extension WeightKey { - public struct DataDisplay: DataDisplayView { - public typealias Key = WeightKey - private let weight: Int - public init(_ value: Int) { - self.weight = value - } - public var body: some View { - HStack { - Text(WeightKey.name) - Spacer() - Text("\(weight) kg") - .foregroundColor(.secondary) - } - .accessibilityElement(children: .combine) - } - } -} -extension WeightKey { - public struct DataEntry: DataEntryView { - public typealias Key = WeightKey - @Binding private var weight: Int - public var body: some View { - HStack { - Text(WeightKey.name) - Spacer() - TextField("Weight", value: $weight, formatter: NumberFormatter()) - .frame(width: 120) // set frame width to enable more spaces. - } - } - public init(_ value: Binding) { - self._weight = value - } - } -} diff --git a/PICS/Resources/ConsentDocument.md b/PICS/Resources/ConsentDocument.md index 5f9df6f..ad494c8 100644 --- a/PICS/Resources/ConsentDocument.md +++ b/PICS/Resources/ConsentDocument.md @@ -1 +1 @@ -Spezi can render consent documents in the markdown format: This is a *markdown* **example**. +The application collects your survey results to analyze your progress. diff --git a/PICS/Resources/Localizable.xcstrings b/PICS/Resources/Localizable.xcstrings index 0f11e8b..47b7b9a 100644 --- a/PICS/Resources/Localizable.xcstrings +++ b/PICS/Resources/Localizable.xcstrings @@ -1,20 +1,11 @@ { "sourceLanguage" : "en", "strings" : { - "" : { - - }, "%@" : { }, "%@: Not Completed" : { - }, - "%lld cm" : { - - }, - "%lld kg" : { - }, "ACCOUNT_NEXT" : { "localizations" : { @@ -199,16 +190,6 @@ } } }, - "ASSESSMENT_STROOP_START_BTN" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Start Assessment" - } - } - } - }, "ASSESSMENT_SUMMARY" : { "extractionState" : "manual", "localizations" : { @@ -296,6 +277,9 @@ } } } + }, + "Cancel" : { + }, "CLOSE" : { "comment" : "MARK: General", @@ -425,9 +409,15 @@ } } } + }, + "Error Count" : { + }, "Failed to get dates" : { + }, + "Get Directions" : { + }, "GETTING_HERE_HEADING" : { "extractionState" : "manual", @@ -481,18 +471,8 @@ } } }, - "Height" : { + "Heart Rate Per Minute" : { - }, - "HEIGHT" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Height" - } - } - } }, "HKVIZ_AVERAGE_STRING" : { "extractionState" : "manual", @@ -753,10 +733,13 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "PICS - Ambulanz: Charité Virchow Clinic" + "value" : "Ambulanz: Charité Virchow Clinic" } } } + }, + "Measurement" : { + }, "Medication Plan" : { @@ -805,17 +788,6 @@ } } }, - "MOCK_WEB_SERVICE_TAB_TITLE" : { - "comment" : "MARK: - Mock Upload Data Storage Provider", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mock Web Service" - } - } - } - }, "NOTIF_CONTENT" : { "extractionState" : "manual", "localizations" : { @@ -847,17 +819,6 @@ } } }, - "NOTIFICATION_PERMISSIONS_SUBTITLE" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Permission to send notifications" - } - } - } - }, "NOTIFICATION_PERMISSIONS_TITLE" : { "localizations" : { "en" : { @@ -880,9 +841,6 @@ } } } - }, - "Onboarding Task" : { - }, "ONBOARDING_CAPTION" : { "localizations" : { @@ -934,6 +892,12 @@ } } } + }, + "Open in Apple Maps" : { + + }, + "Oxygen Saturation (precent)" : { + }, "PERIOD" : { "extractionState" : "manual", @@ -945,6 +909,9 @@ } } } + }, + "Personal Information" : { + }, "PHQ-4_DESCRIPTION" : { "extractionState" : "manual", @@ -967,6 +934,15 @@ } } } + }, + "Preview Title" : { + + }, + "Preview x axis" : { + + }, + "Preview y axis" : { + }, "PROJECT_LICENSE_DESCRIPTION" : { "localizations" : { @@ -977,6 +953,9 @@ } } } + }, + "Reaction Time" : { + }, "REACTIONTIME_TEST" : { "extractionState" : "manual", @@ -1108,6 +1087,9 @@ } } } + }, + "Retake Assessment" : { + }, "SAVE_BUTTON" : { "extractionState" : "manual", @@ -1143,9 +1125,15 @@ }, "Skip" : { + }, + "Start Assessment" : { + }, "Start Questionnaire" : { + }, + "Step Count" : { + }, "STREET_ADDRESS" : { "extractionState" : "manual", @@ -1158,15 +1146,8 @@ } } }, - "STROOP_TEST_TITLE" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Stroop Test" - } - } - } + "Stroop Test" : { + }, "STROOP_VIZ_TITLE" : { "localizations" : { @@ -1233,36 +1214,26 @@ } } }, - "TASK_SOCIAL_SUPPORT_QUESTIONNAIRE_DESCRIPTION" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Please fill out the Patient-Reported Outcome Measures Assessment (PROM) questionnaire every day." - } - } - } - }, - "TASK_SOCIAL_SUPPORT_QUESTIONNAIRE_TITLE" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Patient-Reported Outcome Measures Assessment (PROM) Questionnaire" - } - } - } + "Test Title" : { + }, "This task has been completed!" : { }, - "This test measures your %@." : { + "This test measures your cognitive flexibility and processing speed." : { + + }, + "This test measures your speed and accuracy of response." : { + + }, + "This test measures your visual attention and task switching." : { }, "Threshold" : { + }, + "Time" : { + }, "TIMELINE_TITLE" : { "extractionState" : "manual", @@ -1285,6 +1256,9 @@ } } } + }, + "Trail Making" : { + }, "TRAIL_MAKING_TEST_TITLE" : { "extractionState" : "manual", @@ -1299,12 +1273,6 @@ }, "Upload Photo" : { - }, - "Value" : { - - }, - "Weight" : { - }, "WEIGHT" : { "extractionState" : "manual", diff --git a/PICS/Resources/PROM-Questionnaire.json b/PICS/Resources/PROM-Questionnaire.json deleted file mode 100644 index 2205654..0000000 --- a/PICS/Resources/PROM-Questionnaire.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"PROM Questionnaire - PICS","resourceType":"Questionnaire","language":"en-US","status":"draft","publisher":"Stanford Biodesign Digital Health","meta":{"profile":["http://spezi.health/fhir/StructureDefinition/sdf-Questionnaire"],"tag":[{"system":"urn:ietf:bcp:47","code":"en-US","display":"English"}]},"useContext":[{"code":{"system":"http://hl7.org/fhir/ValueSet/usage-context-type","code":"focus","display":"Clinical Focus"},"valueCodeableConcept":{"coding":[{"system":"urn:oid:2.16.578.1.12.4.1.1.8655","display":"PROM Questionnaire - PICS"}]}}],"contact":[{"name":"http://spezi.health"}],"subjectType":["Patient"],"url":"http://spezi.health/fhir/questionnaire/c71a1dda-e3a2-41d1-930e-6c2186dd3498","contained":[{"url":"http://spezi.health/fhir/ValueSet/Predefined","resourceType":"ValueSet","id":"1101","version":"1.0","name":"urn:oid:1101","title":"Yes / No","status":"draft","publisher":"NHN","compose":{"include":[{"system":"urn:oid:2.16.578.1.12.4.1.1101","concept":[{"code":"1","display":"Yes"},{"code":"2","display":"No"}]}]}}],"item":[{"linkId":"634455a7-f236-41be-87e9-9bc09d7f2afb","type":"display","text":"Completion Instructions:\nPlease complete this questionnaire at a similar time each day.\nAnswer each question based on your experiences and feelings today.\nYour daily input is valuable in monitoring your health and adjusting your treatment as needed.","required":false},{"linkId":"10b65f3b-64fe-467c-87e1-66b992219bbe","type":"group","text":"General Health","item":[{"linkId":"3cf6e8c2-35f2-4550-81d1-152b85a22ece","type":"choice","required":true,"text":"How would you rate your overall health today?","answerOption":[{"valueCoding":{"id":"e62c6558-959f-46e5-97d7-7ecc95e482db","code":"1","system":"urn:uuid:4c11aa39-c234-4943-82a6-08351d8226b1","display":"1"}},{"valueCoding":{"id":"b765f098-96e7-4bc1-8e6e-8e65b736e4fd","code":"2","system":"urn:uuid:4c11aa39-c234-4943-82a6-08351d8226b1","display":"2"}},{"valueCoding":{"id":"4002a98a-9974-4a73-93a8-50f7f69759e2","code":"3","system":"urn:uuid:4c11aa39-c234-4943-82a6-08351d8226b1","display":"3"}},{"valueCoding":{"id":"d9dd9cee-4689-4b38-d399-d60b3088dda8","code":"4","system":"urn:uuid:4c11aa39-c234-4943-82a6-08351d8226b1","display":"4"}},{"valueCoding":{"id":"e069ed46-6de8-40c8-d2bd-a0271750c716","code":"5","system":"urn:uuid:4c11aa39-c234-4943-82a6-08351d8226b1","display":"5"}}]},{"linkId":"ebcd4e00-975a-4664-82f9-f540fcc92f95","type":"integer","text":"How many hours of sleep did you get last night?","required":true}],"required":false},{"linkId":"a947bfd0-c605-4413-96be-ed574b2cf300","type":"group","text":"Physical Health","item":[{"linkId":"ac507846-bdda-4aa3-8617-a1082cf85651","type":"choice","text":"Do you have any muscle weakness?","required":true,"answerValueSet":"#1101"},{"linkId":"ccbddcda-2d1f-4617-9be2-6e28711ff3ac","type":"text","text":"Are there any physical activities you find difficult to perform? (List All)","required":false},{"linkId":"3f4d19f8-6851-439d-840e-8fb95b7342dd","type":"choice","text":"Have you experienced any difficulty in hearing today?","required":true,"answerValueSet":"#1101"},{"linkId":"30ff4f73-c526-4d2d-ce99-668daea01c58","type":"choice","required":true,"text":"Have you experienced any problems in your vision today?","answerValueSet":"#1101"},{"linkId":"3d2316e3-4d4a-4910-816a-918cb751200d","type":"choice","text":"Have you experienced any difficulty in swallowing today?","required":true,"answerValueSet":"#1101"}],"required":false},{"linkId":"7f7c8e10-0792-41d0-bffd-9825b24807a1","type":"group","item":[{"linkId":"d8b16623-430e-45c9-9b6a-a70e76fd20c4","type":"choice","text":"How would you rate your memory and concentration today? (Scale: 1 [Very Poor] - 5 [Excellent])","required":true,"answerOption":[{"valueCoding":{"id":"f70058db-7035-4943-b6cd-98e5b89cad06","code":"1","system":"urn:uuid:0d03bf56-b57a-4cbb-8144-4252c03ede22","display":"1"}},{"valueCoding":{"id":"df8e09da-3fde-46ce-9dd2-6ca9c36fe14f","code":"2","system":"urn:uuid:0d03bf56-b57a-4cbb-8144-4252c03ede22","display":"2"}},{"valueCoding":{"id":"1920e802-6122-4fe8-9598-1b996b1c0cb9","code":"3","system":"urn:uuid:0d03bf56-b57a-4cbb-8144-4252c03ede22","display":"3"}},{"valueCoding":{"id":"f4f675bf-13ae-43c3-eda9-b64a8f2c8fc8","code":"4","system":"urn:uuid:0d03bf56-b57a-4cbb-8144-4252c03ede22","display":"4"}},{"valueCoding":{"id":"6f1cc957-a420-4c92-9617-141442013990","code":"5","system":"urn:uuid:0d03bf56-b57a-4cbb-8144-4252c03ede22","display":"5"}}]},{"linkId":"34330709-4d58-426d-8178-ab8bf27dd580","type":"choice","required":true,"text":"Are you feeling more anxious today compared to yesterday? ","answerValueSet":"#1101"}],"required":false,"text":"Cognitive and Emotional Well-Being"},{"linkId":"eb95fb60-183d-465c-a97f-713414367f09","type":"group","text":"Activity and Mobility","item":[{"linkId":"d6341353-915a-44cd-80b6-a7c9dad59c98","type":"choice","text":"Were you able to perform your daily activities more easily, less easily, or about the same as yesterday?","required":false,"answerOption":[{"valueCoding":{"id":"5d274933-737a-46f1-9cfc-ac0f5a2a598a","code":"more-easily","system":"urn:uuid:fac410d3-1fc9-472e-88ac-c454b2ca3eea","display":"More Easily"}},{"valueCoding":{"id":"1c819c05-935d-4a63-ab4a-f73e694a7e1c","code":"about-the-same","system":"urn:uuid:fac410d3-1fc9-472e-88ac-c454b2ca3eea","display":"About the Same"}},{"valueCoding":{"id":"952f45d5-d500-4553-8033-0ae61de509a7","code":"less-easily","system":"urn:uuid:fac410d3-1fc9-472e-88ac-c454b2ca3eea","display":"Less Easily"}}]},{"linkId":"c3602179-0e9e-44e1-a874-8087117fa604","type":"choice","text":"Did you require assistance with any activities today that you didn't need help with yesterday?","required":false,"answerValueSet":"#1101"}],"required":false},{"linkId":"c0334867-4b87-4df2-8989-2c5723895459","type":"display","text":"Thank you for taking today's questionnaire! Please take this questionnaire at the same scheduled time tomorrow!","required":false}]} \ No newline at end of file diff --git a/PICS/Resources/PROM-Questionnaire.json.license b/PICS/Resources/PROM-Questionnaire.json.license deleted file mode 100644 index 033101e..0000000 --- a/PICS/Resources/PROM-Questionnaire.json.license +++ /dev/null @@ -1,6 +0,0 @@ - -This source file is part of the PICS based on the Stanford Spezi Template Application project - -SPDX-FileCopyrightText: 2023 Stanford University - -SPDX-License-Identifier: MIT diff --git a/PICS/Resources/SocialSupportQuestionnaire.json b/PICS/Resources/SocialSupportQuestionnaire.json deleted file mode 100644 index d3c584d..0000000 --- a/PICS/Resources/SocialSupportQuestionnaire.json +++ /dev/null @@ -1,387 +0,0 @@ -{ - "resourceType": "Questionnaire", - "language": "en-US", - "id": "socialsupport", - "name": "SocialSupport", - "title": "Social Support", - "description": "This survey measures tangible social support plus a couple of demographic questions.", - "version": "1", - "status": "draft", - "publisher": "RAND Corp", - "meta": { - "profile": [ - "http://spezi.stanford.edu/fhir/StructureDefinition/sdf-Questionnaire" - ], - "tag": [ - { - "system": "urn:ietf:bcp:47", - "code": "en-US", - "display": "English" - } - ] - }, - "useContext": [ - { - "code": { - "system": "http://hl7.org/fhir/ValueSet/usage-context-type", - "code": "focus", - "display": "Clinical Focus" - }, - "valueCodeableConcept": { - "coding": [ - { - "system": "urn:oid:2.16.578.1.12.4.1.1.8655", - "display": "Social Support" - } - ] - } - } - ], - "contact": [ - { - "name": "https://www.rand.org/health-care/surveys_tools/mos/social-support/survey-instrument.html" - } - ], - "subjectType": [ - "Patient" - ], - "purpose": "The RAND Medical Outcomes Social Support survey is a 4-item questionnaire that measures social support.", - "copyright": "RAND Corp surveys are open-source and free to use.", - "date": "2023-01-23T00:00:00-08:00", - "url": "http://spezi.stanford.edu/fhir/questionnaire/32f43c8e-93e9-4c70-97a0-e716f8030073", - "item": [ - { - "linkId": "dcea2683-9815-4505-b240-e75b502b29ef", - "type": "choice", - "text": "How often do you need someone to help you if you were confined to bed?", - "required": false, - "answerOption": [ - { - "valueCoding": { - "id": "3d6fe1b8-c64b-497c-8583-db7ddda9e94e", - "code": "1", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "None of the time" - } - }, - { - "valueCoding": { - "id": "b4081e9d-d0f1-4aea-9a15-eac4a15d1d10", - "code": "2", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "A little of the time" - } - }, - { - "valueCoding": { - "id": "e32f7952-e280-48d7-9746-c13dbb26638f", - "code": "3", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "Some of the time" - } - }, - { - "valueCoding": { - "id": "d2f6172d-9402-4cb3-870a-584a7be3a5d7", - "code": "4", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "Most of the time" - } - }, - { - "valueCoding": { - "id": "ec48001e-f03e-4a14-8a7a-9fcf34fa81d2", - "code": "5", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "All of the time" - } - } - ] - }, - { - "linkId": "ce09d701-7b93-4150-defb-51825e05ade9", - "type": "choice", - "text": "How often do you need someone to take you to the doctor if you needed it?", - "required": false, - "answerOption": [ - { - "valueCoding": { - "id": "3d6fe1b8-c64b-497c-8583-db7ddda9e94e", - "code": "1", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "None of the time" - } - }, - { - "valueCoding": { - "id": "b4081e9d-d0f1-4aea-9a15-eac4a15d1d10", - "code": "2", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "A little of the time" - } - }, - { - "valueCoding": { - "id": "e32f7952-e280-48d7-9746-c13dbb26638f", - "code": "3", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "Some of the time" - } - }, - { - "valueCoding": { - "id": "d2f6172d-9402-4cb3-870a-584a7be3a5d7", - "code": "4", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "Most of the time" - } - }, - { - "valueCoding": { - "id": "ec48001e-f03e-4a14-8a7a-9fcf34fa81d2", - "code": "5", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "All of the time" - } - } - ] - }, - { - "linkId": "58e97564-5f4d-4d4b-86d5-6429cbbc7a8e", - "type": "choice", - "text": "How often do you need someone to prepare your meals if you were unable to do it yourself?", - "required": false, - "answerOption": [ - { - "valueCoding": { - "id": "3d6fe1b8-c64b-497c-8583-db7ddda9e94e", - "code": "1", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "None of the time" - } - }, - { - "valueCoding": { - "id": "b4081e9d-d0f1-4aea-9a15-eac4a15d1d10", - "code": "2", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "A little of the time" - } - }, - { - "valueCoding": { - "id": "e32f7952-e280-48d7-9746-c13dbb26638f", - "code": "3", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "Some of the time" - } - }, - { - "valueCoding": { - "id": "d2f6172d-9402-4cb3-870a-584a7be3a5d7", - "code": "4", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "Most of the time" - } - }, - { - "valueCoding": { - "id": "ec48001e-f03e-4a14-8a7a-9fcf34fa81d2", - "code": "5", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "All of the time" - } - } - ] - }, - { - "linkId": "ad161c49-e8a6-4d31-90e8-02b2887a765f", - "type": "choice", - "text": "How often do you need someone to help with daily chores if you were sick", - "required": false, - "answerOption": [ - { - "valueCoding": { - "id": "3d6fe1b8-c64b-497c-8583-db7ddda9e94e", - "code": "1", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "None of the time" - } - }, - { - "valueCoding": { - "id": "b4081e9d-d0f1-4aea-9a15-eac4a15d1d10", - "code": "2", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "A little of the time" - } - }, - { - "valueCoding": { - "id": "e32f7952-e280-48d7-9746-c13dbb26638f", - "code": "3", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "Some of the time" - } - }, - { - "valueCoding": { - "id": "d2f6172d-9402-4cb3-870a-584a7be3a5d7", - "code": "4", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "Most of the time" - } - }, - { - "valueCoding": { - "id": "ec48001e-f03e-4a14-8a7a-9fcf34fa81d2", - "code": "5", - "system": "urn:uuid:e9ecdd47-2e8b-49b3-8780-9d0769a246aa", - "display": "All of the time" - } - } - ] - }, - { - "linkId": "ba518851-2843-4bbd-c0f7-5b5692d542e0", - "type": "integer", - "text": "What is your age?", - "extension": [ - { - "url": "http://hl7.org/fhir/StructureDefinition/minValue", - "valueInteger": 18 - }, - { - "url": "http://hl7.org/fhir/StructureDefinition/maxValue", - "valueInteger": 120 - }, - { - "url": "http://biodesign.stanford.edu/fhir/StructureDefinition/validationtext", - "valueString": "Please enter a valid age." - } - ], - "required": false - }, - { - "linkId": "695525f3-3e89-4455-8e25-878171c596da", - "type": "choice", - "text": "What is your preferred contact method?", - "required": false, - "answerOption": [ - { - "valueCoding": { - "id": "b7a3d7a5-52b9-49b1-8b59-7a3885483f1c", - "code": "phone-call", - "system": "urn:uuid:736ac230-812a-4f4a-edec-5156910fb6ec", - "display": "Phone call" - } - }, - { - "valueCoding": { - "id": "3d42dde0-8e60-4832-bd46-bd06de28cbf2", - "code": "text-message", - "system": "urn:uuid:736ac230-812a-4f4a-edec-5156910fb6ec", - "display": "Text message" - } - }, - { - "valueCoding": { - "id": "e672cfc6-118f-4a2d-aafd-02722ff876b9", - "code": "e-mail", - "system": "urn:uuid:736ac230-812a-4f4a-edec-5156910fb6ec", - "display": "E-mail" - } - } - ] - }, - { - "linkId": "c3bea33d-4c50-4f4a-8ae4-1a52be326b19", - "type": "string", - "text": "What is your phone number? Ex. (555) 555-5555", - "required": false, - "enableWhen": [ - { - "question": "695525f3-3e89-4455-8e25-878171c596da", - "operator": "=", - "answerCoding": { - "system": "urn:uuid:736ac230-812a-4f4a-edec-5156910fb6ec", - "code": "phone-call" - } - } - ], - "extension": [ - { - "url": "http://hl7.org/fhir/StructureDefinition/regex", - "valueString": "^(\\([0-9]{3}\\) |[0-9]{3}-)[0-9]{3}-[0-9]{4}$" - }, - { - "url": "http://biodesign.stanford.edu/fhir/StructureDefinition/validationtext", - "valueString": "Please enter a valid phone number." - } - ] - }, - { - "linkId": "8e906a39-5fd0-42a8-f42c-bd96d719dd13", - "type": "string", - "text": "What is your text number? Ex. (555) 555-5555", - "required": false, - "enableWhen": [ - { - "question": "695525f3-3e89-4455-8e25-878171c596da", - "operator": "=", - "answerCoding": { - "system": "urn:uuid:736ac230-812a-4f4a-edec-5156910fb6ec", - "code": "text-message" - } - } - ], - "extension": [ - { - "url": "http://hl7.org/fhir/StructureDefinition/regex", - "valueString": "^(\\([0-9]{3}\\) |[0-9]{3}-)[0-9]{3}-[0-9]{4}$" - }, - { - "url": "http://biodesign.stanford.edu/fhir/StructureDefinition/validationtext", - "valueString": "Please enter a valid phone number." - } - ] - }, - { - "linkId": "86290b0a-017e-4193-8707-dc0c2146f0eb", - "type": "string", - "text": "What is your e-mail?", - "extension": [ - { - "url": "http://hl7.org/fhir/StructureDefinition/regex", - "valueString": ".*@.+" - }, - { - "url": "http://biodesign.stanford.edu/fhir/StructureDefinition/validationtext", - "valueString": "Please enter a valid email" - }, - { - "url": "http://hl7.org/fhir/StructureDefinition/minLength", - "valueInteger": 1 - } - ], - "required": false, - "maxLength": 50, - "enableWhen": [ - { - "question": "695525f3-3e89-4455-8e25-878171c596da", - "operator": "=", - "answerCoding": { - "system": "urn:uuid:736ac230-812a-4f4a-edec-5156910fb6ec", - "code": "e-mail" - } - } - ] - }, - { - "linkId": "305f5381-2d8b-4b98-bc04-5a39bee2f7ec", - "type": "display", - "text": "Thank you for taking the survey!", - "required": false - } - ] -} diff --git a/PICS/Resources/SocialSupportQuestionnaire.json.license b/PICS/Resources/SocialSupportQuestionnaire.json.license deleted file mode 100644 index 033101e..0000000 --- a/PICS/Resources/SocialSupportQuestionnaire.json.license +++ /dev/null @@ -1,6 +0,0 @@ - -This source file is part of the PICS based on the Stanford Spezi Template Application project - -SPDX-FileCopyrightText: 2023 Stanford University - -SPDX-License-Identifier: MIT diff --git a/PICS/Schedule/EventContextView.swift b/PICS/Schedule/EventContextView.swift index 6bb45de..1d3b3bf 100644 --- a/PICS/Schedule/EventContextView.swift +++ b/PICS/Schedule/EventContextView.swift @@ -31,7 +31,7 @@ struct EventContextView: View { Divider() if !eventContext.event.complete { descriptionText - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) } else { completedText } diff --git a/PICS/Schedule/ModalView.swift b/PICS/Schedule/ModalView.swift index edb460d..7afe31c 100644 --- a/PICS/Schedule/ModalView.swift +++ b/PICS/Schedule/ModalView.swift @@ -11,8 +11,9 @@ import SwiftUI struct ModalView: View { - @Environment(\.dismiss) private var dismiss - + @Environment(\.dismiss) + private var dismiss + let text: String let buttonText: String let onClose: () async -> Void diff --git a/PICS/Schedule/OnboardingSurveyView.swift b/PICS/Schedule/OnboardingSurveyView.swift index c4ee2fe..2999e41 100644 --- a/PICS/Schedule/OnboardingSurveyView.swift +++ b/PICS/Schedule/OnboardingSurveyView.swift @@ -8,15 +8,16 @@ import SwiftUI struct OnboardingSurveyView: View { - @Environment(PICSStandard.self) private var standard - @AppStorage("isSurveyCompleted") var isSurveyCompleted = false + @Environment(PICSStandard.self) + private var standard + @State var isSheetPresented = false var body: some View { HStack { onboardingSurveyDetailsStack } - .contentShape(Rectangle()) + .contentShape(Rectangle()) } private var onboardingSurveyDetailsStack: some View { @@ -59,9 +60,9 @@ struct OnboardingSurveyView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) .padding(.top, 8) } - .sheet(isPresented: $isSheetPresented) { - OnboardingQuestionnaireDashboard(isSheetPresented: $isSheetPresented) - } + .sheet(isPresented: $isSheetPresented) { + PersonalInformationQuestionnaire() + } } } diff --git a/PICS/Schedule/PICSScheduler.swift b/PICS/Schedule/PICSScheduler.swift index 7c950c0..837f996 100644 --- a/PICS/Schedule/PICSScheduler.swift +++ b/PICS/Schedule/PICSScheduler.swift @@ -16,52 +16,52 @@ typealias PICSScheduler = Scheduler extension PICSScheduler { - static var PHQ4Task: SpeziScheduler.Task { - let dateComponents: DateComponents - if FeatureFlags.testSchedule { - // Adds a task at the current time + 1 minute for UI testing - dateComponents = DateComponents( - hour: Calendar.current.component(.hour, from: .now), - minute: Calendar.current.component(.minute, from: .now).addingReportingOverflow(1).partialValue - ) - } else { - // Schedule the task for every 2 weeks at 8:00 AM for normal app usage - dateComponents = DateComponents(hour: 8, minute: 0) - } + private static var baseDateComponent: DateComponents { + let weekday = Calendar.current.component(.weekday, from: .now) - return Task( - title: String(localized: "PHQ-4_TITLE"), - description: String(localized: "PHQ-4_DESCRIPTION"), - schedule: Schedule( - start: Calendar.current.startOfDay(for: Date()), - repetition: .matching(dateComponents), - end: .numberOfEvents(26) - ), - notifications: true, - context: PICSTaskContext.questionnaire(Bundle.main.questionnaire(withName: "PHQ-4")) - ) - } - - static var EQ5D5LTask: SpeziScheduler.Task { let dateComponents: DateComponents if FeatureFlags.testSchedule { - // Adds a task at the current time for UI testing if the `--testSchedule` feature flag is set dateComponents = DateComponents( hour: Calendar.current.component(.hour, from: .now), minute: Calendar.current.component(.minute, from: .now) ) } else { - // For the normal app usage, we schedule the task for every 2 weeks at 8:05 AM - dateComponents = DateComponents(hour: 8, minute: 5) + // Schedule the task for every 2 weeks at 8:00 AM for normal app usage + dateComponents = DateComponents(hour: 8, minute: 0, weekday: weekday) } + return dateComponents + } + + private static let repetitions = 6 * 4 + + static var PHQ4Task: SpeziScheduler.Task { + let dateComponents = baseDateComponent + + return Task( + title: String(localized: "PHQ-4_TITLE"), + description: String(localized: "PHQ-4_DESCRIPTION"), + schedule: Schedule( + start: Calendar.current.startOfDay(for: Date()), + repetition: .matching(dateComponents), + end: .numberOfEvents(repetitions) + ), + notifications: true, + context: PICSTaskContext.questionnaire(Bundle.main.questionnaire(withName: "PHQ-4")) + ) + } + + static var EQ5D5LTask: SpeziScheduler.Task { + var dateComponents = baseDateComponent + dateComponents.second = 1 + return Task( title: String(localized: "EQ5D5L_TITLE"), description: String(localized: "EQ5D5L_DESCRIPTION"), schedule: Schedule( - start: Calendar.current.startOfDay(for: Date()), + start: Calendar.current.startOfDay(for: .now), repetition: .matching(dateComponents), - end: .numberOfEvents(26) + end: .numberOfEvents(repetitions) ), notifications: true, context: PICSTaskContext.questionnaire(Bundle.main.questionnaire(withName: "EQ5D5L")) @@ -69,25 +69,16 @@ extension PICSScheduler { } static var MiniNutritionalTask: SpeziScheduler.Task { - let dateComponents: DateComponents - if FeatureFlags.testSchedule { - // Adds a task at the current time for UI testing if the `--testSchedule` feature flag is set - dateComponents = DateComponents( - hour: Calendar.current.component(.hour, from: .now), - minute: Calendar.current.component(.minute, from: .now) - ) - } else { - // For the normal app usage we schedule the task for every 2 weeks at 8:10 AM - dateComponents = DateComponents(hour: 8, minute: 10) - } + var dateComponents = baseDateComponent + dateComponents.second = 2 return Task( title: String(localized: "MiniNutritional_TITLE"), description: String(localized: "MiniNutritional_DESCRIPTION"), schedule: Schedule( - start: Calendar.current.startOfDay(for: Date()), + start: Calendar.current.startOfDay(for: .now), repetition: .matching(dateComponents), - end: .numberOfEvents(26) + end: .numberOfEvents(repetitions) ), notifications: true, context: PICSTaskContext.questionnaire(Bundle.main.questionnaire(withName: "Self-MNA")) diff --git a/PICS/Appointment/AppointmentInformation.swift b/PICS/Schedule/PatientInformation/PatientInformation.swift similarity index 74% rename from PICS/Appointment/AppointmentInformation.swift rename to PICS/Schedule/PatientInformation/PatientInformation.swift index 1d5f1b7..231db43 100644 --- a/PICS/Appointment/AppointmentInformation.swift +++ b/PICS/Schedule/PatientInformation/PatientInformation.swift @@ -10,76 +10,71 @@ import Foundation import SwiftUI import UserNotifications + @Observable -class AppointmentInformation { - @AppStorage("appt0") @ObservationIgnored private var _appt0Data: Data? - @AppStorage("appt1") @ObservationIgnored private var _appt1Data: Data? - @AppStorage("appt2") @ObservationIgnored private var _appt2Data: Data? - - var appt0Data: Data? { +class PatientInformation { + @AppStorage("appt0") + @ObservationIgnored private var _appt0Data: Date = .now + @AppStorage("appt1") + @ObservationIgnored private var _appt1Data: Date = .now + @AppStorage("appt2") + @ObservationIgnored private var _appt2Data: Date = .now + + @AppStorage("isSurveyCompleted") + @ObservationIgnored private var _isSurveyCompleted = false + + var appt0: Date { get { - self.access(keyPath: \.appt0Data) + self.access(keyPath: \.appt0) return _appt0Data } set { - self.withMutation(keyPath: \.appt0Data) { + self.withMutation(keyPath: \.appt0) { self._appt0Data = newValue } } } - var appt1Data: Data? { + var appt1: Date { get { - self.access(keyPath: \.appt1Data) + self.access(keyPath: \.appt1) return _appt1Data } set { - self.withMutation(keyPath: \.appt1Data) { + self.withMutation(keyPath: \.appt1) { self._appt1Data = newValue } } } - var appt2Data: Data? { + var appt2: Date { get { - self.access(keyPath: \.appt2Data) + self.access(keyPath: \.appt2) return _appt2Data } set { - self.withMutation(keyPath: \.appt2Data) { + self.withMutation(keyPath: \.appt2) { self._appt2Data = newValue } } } - - var appt0: Date { - let decoder = JSONDecoder() - if let appt0Data, let decodedAppt0 = try? decoder.decode(Date.self, from: appt0Data) { - return decodedAppt0 - } - return Date() - } - - var appt1: Date { - let decoder = JSONDecoder() - if let appt1Data, let decodedAppt1 = try? decoder.decode(Date.self, from: appt1Data) { - return decodedAppt1 + + var isSurveyCompleted: Bool { + get { + self.access(keyPath: \.isSurveyCompleted) + return _isSurveyCompleted } - return Date() - } - - var appt2: Date { - let decoder = JSONDecoder() - if let appt2Data, let decodedAppt2 = try? decoder.decode(Date.self, from: appt2Data) { - return decodedAppt2 + set { + self.withMutation(keyPath: \.isSurveyCompleted) { + self._isSurveyCompleted = newValue + } } - return Date() } - + func storeDates(_ date0: Date, _ date1: Date, _ date2: Date) { - appt1Data = try? JSONEncoder().encode(date0) - appt1Data = try? JSONEncoder().encode(date1) - appt2Data = try? JSONEncoder().encode(date2) + self.appt0 = date0 + self.appt1 = date1 + self.appt2 = date2 let notificationCenter = UNUserNotificationCenter.current() notificationCenter.removeAllPendingNotificationRequests() @@ -148,3 +143,10 @@ class AppointmentInformation { return identifier } } + + +extension Date: CodableRawRepresentable { + var defaultJson: String { + "" + } +} diff --git a/PICS/Schedule/OnboardingQuestionnaireDashboard.swift b/PICS/Schedule/PatientInformation/PersonalInformationQuestionnaire.swift similarity index 52% rename from PICS/Schedule/OnboardingQuestionnaireDashboard.swift rename to PICS/Schedule/PatientInformation/PersonalInformationQuestionnaire.swift index f2d3da8..b46871a 100644 --- a/PICS/Schedule/OnboardingQuestionnaireDashboard.swift +++ b/PICS/Schedule/PatientInformation/PersonalInformationQuestionnaire.swift @@ -5,32 +5,49 @@ // // SPDX-License-Identifier: MIT // -import SpeziOnboarding + import SpeziQuestionnaire import SwiftUI -struct OnboardingQuestionnaireDashboard: View { - @Environment(PICSStandard.self) private var standard - @AppStorage("isSurveyCompleted") var isSurveyCompleted = false - @Binding var isSheetPresented: Bool + + +struct PersonalInformationQuestionnaire: View { + private let onCompletion: () -> Void + + @Environment(\.dismiss) + private var dismiss + + @Environment(PICSStandard.self) + private var standard + @Environment(PatientInformation.self) + private var patientInformation var body: some View { QuestionnaireView( questionnaire: Bundle.main.questionnaire(withName: "Onboarding-Questionnaire") ) { result in + dismiss() + guard case let .completed(response) = result else { - isSheetPresented = false return // user cancelled } - isSurveyCompleted = true - isSheetPresented = false + patientInformation.isSurveyCompleted = true + await standard.add(response: response) + onCompletion() } } + + + init(onCompletion: @escaping () -> Void = {}) { + self.onCompletion = onCompletion + } } + +#if DEBUG #Preview { - OnboardingStack { - OnboardingQuestionnaireDashboard(isSheetPresented: .constant(true)) - } + PersonalInformationQuestionnaire() + .environment(PatientInformation()) .previewWith(standard: PICSStandard()) {} } +#endif diff --git a/PICS/Schedule/ScheduleView.swift b/PICS/Schedule/ScheduleView.swift index ed5f20f..ec0674b 100644 --- a/PICS/Schedule/ScheduleView.swift +++ b/PICS/Schedule/ScheduleView.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import OrderedCollections import SpeziAccount import SpeziQuestionnaire import SpeziScheduler @@ -13,27 +14,50 @@ import SwiftUI struct ScheduleView: View { - @Environment(PICSStandard.self) private var standard - @Environment(PICSScheduler.self) private var scheduler - @State private var eventContextsByDate: [Date: [EventContext]] = [:] - @State private var presentedContext: EventContext? + @Environment(PICSStandard.self) + private var standard + @Environment(PICSScheduler.self) + private var scheduler + @Environment(PatientInformation.self) + private var patientInformation + @Environment(\.scenePhase) + private var scenePhase + @State private var presentedContext: EventContext? @Binding private var presentingAccount: Bool - @AppStorage("isSurveyCompleted") var isSurveyCompleted = false - - private var startOfDays: [Date] { - Array(eventContextsByDate.keys) + + + private var eventContextsByDate: OrderedDictionary { + let eventContexts = scheduler.tasks.flatMap { task in + task + .events( + from: Calendar.current.startOfDay(for: .now), + to: .numberOfEventsOrEndDate(100, .now) + ) + .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 { List { - if !isSurveyCompleted { - Section(header: Text("Onboarding Task")) { + if !patientInformation.isSurveyCompleted { + Section("Personal Information") { OnboardingSurveyView() } } - ForEach(startOfDays, id: \.timeIntervalSinceNow) { startOfDay in + + let eventContextsByDate = eventContextsByDate + + ForEach(eventContextsByDate.keys, id: \.timeIntervalSinceNow) { startOfDay in Section(format(startOfDay: startOfDay)) { ForEach(eventContextsByDate[startOfDay] ?? [], id: \.event) { eventContext in EventContextView(eventContext: eventContext) @@ -46,12 +70,6 @@ struct ScheduleView: View { } } } - .onChange(of: scheduler) { - calculateEventContextsByDate() - } - .task { - calculateEventContextsByDate() - } .sheet(item: $presentedContext) { presentedContext in destination(withContext: presentedContext) } @@ -99,32 +117,13 @@ struct ScheduleView: View { dateFormatter.timeStyle = .none return dateFormatter.string(from: startOfDay) } - - private func calculateEventContextsByDate() { - let eventContexts = scheduler.tasks.flatMap { task in - task - .events( - from: Calendar.current.startOfDay(for: .now), - to: .numberOfEventsOrEndDate(100, .now) - ) - .map { event in - EventContext(event: event, task: task) - } - } - .sorted() - - let newEventContextsByDate = Dictionary(grouping: eventContexts) { eventContext in - Calendar.current.startOfDay(for: eventContext.event.scheduledAt) - } - - eventContextsByDate = newEventContextsByDate - } } #if DEBUG #Preview("ScheduleView") { ScheduleView(presentingAccount: .constant(false)) + .environment(PatientInformation()) .previewWith(standard: PICSStandard()) { PICSScheduler() AccountConfiguration { diff --git a/PICSTests/ApptInfoTests.swift b/PICSTests/PatientInformationTests.swift similarity index 81% rename from PICSTests/ApptInfoTests.swift rename to PICSTests/PatientInformationTests.swift index f93ee67..3b34d80 100644 --- a/PICSTests/ApptInfoTests.swift +++ b/PICSTests/PatientInformationTests.swift @@ -9,10 +9,10 @@ @testable import PICS import XCTest -class AppointmentInformationTests: XCTestCase { +class PatientInformationTests: XCTestCase { // Test storing and retrieving dates func testStoreAndRetrieveDates() { - let appointmentInformation = AppointmentInformation() + let patientInformation = PatientInformation() let calendar = Calendar.current let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" @@ -27,11 +27,11 @@ class AppointmentInformationTests: XCTestCase { print("Error: Failed to calculate date six months ahead") return } - appointmentInformation.storeDates(testDate, threeMonthsAhead, sixMonthsAhead) - - let retrievedDate0 = appointmentInformation.appt0 - let retrievedDate1 = appointmentInformation.appt1 - let retrievedDate2 = appointmentInformation.appt2 + patientInformation.storeDates(testDate, threeMonthsAhead, sixMonthsAhead) + + let retrievedDate0 = patientInformation.appt0 + let retrievedDate1 = patientInformation.appt1 + let retrievedDate2 = patientInformation.appt2 let testDateString = dateFormatter.string(from: testDate) let threeMonthsAheadString = dateFormatter.string(from: threeMonthsAhead) diff --git a/PICSUITests/OnboardingTests.swift b/PICSUITests/OnboardingTests.swift index 5e00006..1d01b20 100644 --- a/PICSUITests/OnboardingTests.swift +++ b/PICSUITests/OnboardingTests.swift @@ -153,9 +153,6 @@ extension XCUIApplication { try textFields["enter first name"].enter(value: "Leland") try textFields["enter last name"].enter(value: "Stanford") - try textFields["Height"].enter(value: "10") - try textFields["Weight"].enter(value: "20") - swipeUp() XCTAssertTrue(collectionViews.buttons["Signup"].waitForExistence(timeout: 2)) @@ -312,7 +309,6 @@ extension XCUIApplication { XCTAssertTrue(staticTexts["Account Overview"].waitForExistence(timeout: 5.0)) XCTAssertTrue(staticTexts["Leland Stanford"].exists) XCTAssertTrue(staticTexts[email].exists) - XCTAssertTrue(staticTexts["Gender Identity, Choose not to answer"].exists) // Check for licencing. XCTAssertTrue(buttons["License Information"].waitForExistence(timeout: 2)) buttons["License Information"].tap() @@ -366,7 +362,7 @@ extension XCUIApplication { XCTAssertTrue(buttons["Questionnaires"].waitForExistence(timeout: 2)) buttons["Questionnaires"].tap() - XCTAssertTrue(staticTexts["ONBOARDING TASK"].waitForExistence(timeout: 2)) + XCTAssertTrue(staticTexts["PERSONAL INFORMATION"].waitForExistence(timeout: 2)) XCTAssertTrue(staticTexts["Onboarding Questionnaire"].waitForExistence(timeout: 2)) } }