diff --git a/CITATION.cff b/CITATION.cff index 67e6404..f0f47ec 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -12,10 +12,17 @@ authors: - family-names: "Schmiedmayer" given-names: "Paul" orcid: "https://orcid.org/0000-0002-8607-9148" -- family-names: "Ravi" - given-names: "Vishnu" - orcid: "https://orcid.org/0000-0003-0359-1275" -- family-names: "Aalami" - given-names: "Oliver" - orcid: "https://orcid.org/0000-0002-7799-2429" +- family-names: "Jörke" + given-names: "Matthew" + orcid: "https://orcid.org/0000-0003-2972-462X" +- family-names: "Jimenez" + given-names: "Bryant" +- family-names: "Hur" + given-names: "Evelyn" +- family-names: "Tran" + given-names: "Caroline" +- family-names: "Song" + given-names: "Evelyn" +- family-names: "Naik" + given-names: "Dhruv" title: "Prisma" diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 6adc7bd..d000567 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -12,6 +12,12 @@ Prisma Contributors ================================= * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) +* [Matthew Jörke](https://github.com/mjoerke) +* [Bryant Jimenez](https://github.com/bryant-jimenez) +* [Evelyn Hur](https://github.com/evelyn-hur) +* [Caroline Tran](https://github.com/carolinentran) +* [Evelyn Song](https://github.com/EvelynBunnyDev) +* [Dhruv Naik](https://github.com/dhruvna1k) * [Andreas Bauer](https://github.com/Supereg) * [Philipp Zagar](https://github.com/philippzagar) * [Nikolai Madlener](https://github.com/NikolaiMadlener) \ No newline at end of file diff --git a/Prisma.xcodeproj/project.pbxproj b/Prisma.xcodeproj/project.pbxproj index 0791fe0..c3db373 100644 --- a/Prisma.xcodeproj/project.pbxproj +++ b/Prisma.xcodeproj/project.pbxproj @@ -8,13 +8,27 @@ /* Begin PBXBuildFile section */ 27FA29902A388E9B009CAC45 /* ModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FA298F2A388E9B009CAC45 /* ModalView.swift */; }; + 2F116D1E2BA79FAF00600A71 /* Date+ISOFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F116D1D2BA79FAF00600A71 /* Date+ISOFormat.swift */; }; + 2F116D202BA79FE400600A71 /* PrismaStandard+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F116D1F2BA79FE400600A71 /* PrismaStandard+Account.swift */; }; + 2F116D242BA7A10400600A71 /* PrismaStandard+Consent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F116D232BA7A10400600A71 /* PrismaStandard+Consent.swift */; }; + 2F116D282BA7A26F00600A71 /* Date+ConstructTimeIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F116D272BA7A26F00600A71 /* Date+ConstructTimeIndex.swift */; }; + 2F116D2B2BA7A37300600A71 /* Date+ISOFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F116D1D2BA79FAF00600A71 /* Date+ISOFormat.swift */; }; + 2F116D2E2BA7AB9000600A71 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F116D2D2BA7AB9000600A71 /* Constants.swift */; }; + 2F116D302BA7ACB800600A71 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F116D2D2BA7AB9000600A71 /* Constants.swift */; }; + 2F116D322BA7ACE800600A71 /* StorageKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DC3F29EDD7EE004B9AB4 /* StorageKeys.swift */; }; + 2F116D342BA7ACEB00600A71 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DC3E29EDD7ED004B9AB4 /* FeatureFlags.swift */; }; + 2F116D372BA7C72700600A71 /* PrivacyDetailHideByTimeRangeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F116D362BA7C72700600A71 /* PrivacyDetailHideByTimeRangeSection.swift */; }; + 2F116D3A2BA7C79900600A71 /* PrivacyDetailHideByListSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F116D392BA7C79900600A71 /* PrivacyDetailHideByListSection.swift */; }; + 2F116D3D2BA7CC3C00600A71 /* HKSampleType+UIElements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F116D3C2BA7CC3C00600A71 /* HKSampleType+UIElements.swift */; }; + 2F116D402BA7F8B800600A71 /* HKSampleType+Encodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F116D3F2BA7F8B800600A71 /* HKSampleType+Encodable.swift */; }; + 2F116D432BA7F90500600A71 /* HKSampleTypeDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F116D422BA7F90500600A71 /* HKSampleTypeDecodable.swift */; }; + 2F116D462BA801E100600A71 /* PrivacyDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F116D452BA801E100600A71 /* PrivacyDetailViewModel.swift */; }; 2F1AC9DF2B4E840E00C24973 /* Prisma.docc in Sources */ = {isa = PBXBuildFile; fileRef = 2F1AC9DE2B4E840E00C24973 /* Prisma.docc */; }; 2F249FA42BA1826100D74C12 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = ACB5DFB92B9F9E76004F28E6 /* GoogleService-Info.plist */; }; 2F3D4ABC2A4E7C290068FB2F /* SpeziScheduler in Frameworks */ = {isa = PBXBuildFile; productRef = 2F3D4ABB2A4E7C290068FB2F /* SpeziScheduler */; }; 2F49B7762980407C00BCB272 /* Spezi in Frameworks */ = {isa = PBXBuildFile; productRef = 2F49B7752980407B00BCB272 /* Spezi */; }; 2F4E237E2989A2FE0013F3D9 /* LaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F4E237D2989A2FE0013F3D9 /* LaunchTests.swift */; }; 2F4E23832989D51F0013F3D9 /* PrismaTestingSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F4E23822989D51F0013F3D9 /* PrismaTestingSetup.swift */; }; - 2F4FC8D729EE69D300BFFE26 /* MockUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F4FC8D629EE69D300BFFE26 /* MockUpload.swift */; }; 2F5E32BD297E05EA003432F8 /* PrismaDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F5E32BC297E05EA003432F8 /* PrismaDelegate.swift */; }; 2FA0BFED2ACC977500E0EF83 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */; }; 2FB099AF2A875DF100B20952 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB099AE2A875DF100B20952 /* FirebaseAuth */; }; @@ -53,7 +67,6 @@ 2FE5DC9929EDD9D9004B9AB4 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 2FE5DC9829EDD9D9004B9AB4 /* XCTestExtensions */; }; 2FE5DC9C29EDD9EF004B9AB4 /* XCTHealthKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2FE5DC9B29EDD9EF004B9AB4 /* XCTHealthKit */; }; 2FE5DCB129EE6107004B9AB4 /* AccountOnboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DCAC29EE6107004B9AB4 /* AccountOnboarding.swift */; }; - 2FF53D8B2A8725DE00042B76 /* SpeziMockWebService in Frameworks */ = {isa = PBXBuildFile; productRef = 2FF53D8A2A8725DE00042B76 /* SpeziMockWebService */; }; 2FF53D8D2A8729D600042B76 /* PrismaStandard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF53D8C2A8729D600042B76 /* PrismaStandard.swift */; }; 5661551D2AB8384200209B80 /* SwiftPackageList in Frameworks */ = {isa = PBXBuildFile; productRef = 5661551C2AB8384200209B80 /* SwiftPackageList */; }; 566155292AB8447C00209B80 /* Package+LicenseType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 566155282AB8447C00209B80 /* Package+LicenseType.swift */; }; @@ -80,10 +93,8 @@ AC69903E2B6C5A2F00D92970 /* PrivacyModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC69903D2B6C5A2F00D92970 /* PrivacyModule.swift */; }; ACB5DFBA2B9F9E76004F28E6 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = ACB5DFB92B9F9E76004F28E6 /* GoogleService-Info.plist */; }; D8027E912B90655700BB9466 /* ManageDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8027E902B90655700BB9466 /* ManageDataView.swift */; }; - D8F136C52B85CEED000BA7AE /* DeleteDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F136C42B85CEED000BA7AE /* DeleteDataView.swift */; }; + D8F136C52B85CEED000BA7AE /* PrivacyDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F136C42B85CEED000BA7AE /* PrivacyDetailView.swift */; }; E4C766262B72D50500C1DEDA /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4C766252B72D50500C1DEDA /* WebView.swift */; }; - F83B7CBE2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83B7CBD2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift */; }; - F8AF6F9A2B5F2B1A0011C32D /* AppIcon-NoBG.png in Resources */ = {isa = PBXBuildFile; fileRef = F8AF6F992B5F2B1A0011C32D /* AppIcon-NoBG.png */; }; F8AF6F9F2B5F35400011C32D /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8AF6F9E2B5F35400011C32D /* ChatView.swift */; }; F8AF6FA52B5F3AE70011C32D /* EventContextCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8AF6FA42B5F3AE70011C32D /* EventContextCard.swift */; }; F8AF6FAC2B5F42C40011C32D /* afternoon-en-US.json in Resources */ = {isa = PBXBuildFile; fileRef = F8AF6FA82B5F42C40011C32D /* afternoon-en-US.json */; }; @@ -91,7 +102,7 @@ F8AF6FAE2B5F42C40011C32D /* morning-en-US.json in Resources */ = {isa = PBXBuildFile; fileRef = F8AF6FAA2B5F42C40011C32D /* morning-en-US.json */; }; F8AF6FAF2B5F42C40011C32D /* mid-day-en-US.json in Resources */ = {isa = PBXBuildFile; fileRef = F8AF6FAB2B5F42C40011C32D /* mid-day-en-US.json */; }; F8AF6FB42B5F6EDC0011C32D /* PrismaModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8AF6FB32B5F6EDC0011C32D /* PrismaModule.swift */; }; - F8AF6FB62B5F71460011C32D /* PrismaStandard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8AF6FB52B5F71460011C32D /* PrismaStandard+Extension.swift */; }; + F8AF6FB62B5F71460011C32D /* String+HealthKitIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8AF6FB52B5F71460011C32D /* String+HealthKitIdentifier.swift */; }; F8AF6FB92B5F72650011C32D /* PrismaStandard+HealthKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8AF6FB82B5F72650011C32D /* PrismaStandard+HealthKit.swift */; }; F8AF6FBC2B5F74EA0011C32D /* PrismaStandard+Questionnaire.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8AF6FBB2B5F74EA0011C32D /* PrismaStandard+Questionnaire.swift */; }; F8D35CE22B60A909001A8FA5 /* NotificationPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8D35CE12B60A909001A8FA5 /* NotificationPermissions.swift */; }; @@ -137,10 +148,20 @@ /* Begin PBXFileReference section */ 27FA298F2A388E9B009CAC45 /* ModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalView.swift; sourceTree = ""; }; + 2F116D1D2BA79FAF00600A71 /* Date+ISOFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+ISOFormat.swift"; sourceTree = ""; }; + 2F116D1F2BA79FE400600A71 /* PrismaStandard+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PrismaStandard+Account.swift"; sourceTree = ""; }; + 2F116D232BA7A10400600A71 /* PrismaStandard+Consent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PrismaStandard+Consent.swift"; sourceTree = ""; }; + 2F116D272BA7A26F00600A71 /* Date+ConstructTimeIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+ConstructTimeIndex.swift"; sourceTree = ""; }; + 2F116D2D2BA7AB9000600A71 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 2F116D362BA7C72700600A71 /* PrivacyDetailHideByTimeRangeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDetailHideByTimeRangeSection.swift; sourceTree = ""; }; + 2F116D392BA7C79900600A71 /* PrivacyDetailHideByListSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDetailHideByListSection.swift; sourceTree = ""; }; + 2F116D3C2BA7CC3C00600A71 /* HKSampleType+UIElements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKSampleType+UIElements.swift"; sourceTree = ""; }; + 2F116D3F2BA7F8B800600A71 /* HKSampleType+Encodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKSampleType+Encodable.swift"; sourceTree = ""; }; + 2F116D422BA7F90500600A71 /* HKSampleTypeDecodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKSampleTypeDecodable.swift; sourceTree = ""; }; + 2F116D452BA801E100600A71 /* PrivacyDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDetailViewModel.swift; sourceTree = ""; }; 2F1AC9DE2B4E840E00C24973 /* Prisma.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Prisma.docc; sourceTree = ""; }; 2F4E237D2989A2FE0013F3D9 /* LaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchTests.swift; sourceTree = ""; }; 2F4E23822989D51F0013F3D9 /* PrismaTestingSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrismaTestingSetup.swift; sourceTree = ""; }; - 2F4FC8D629EE69D300BFFE26 /* MockUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUpload.swift; sourceTree = ""; }; 2F5E32BC297E05EA003432F8 /* PrismaDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrismaDelegate.swift; sourceTree = ""; }; 2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 2FAEC07F297F583900C11C42 /* Prisma.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Prisma.entitlements; sourceTree = ""; }; @@ -188,10 +209,8 @@ AC69903D2B6C5A2F00D92970 /* PrivacyModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyModule.swift; sourceTree = ""; }; ACB5DFB92B9F9E76004F28E6 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; D8027E902B90655700BB9466 /* ManageDataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageDataView.swift; sourceTree = ""; }; - D8F136C42B85CEED000BA7AE /* DeleteDataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteDataView.swift; sourceTree = ""; }; + D8F136C42B85CEED000BA7AE /* PrivacyDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDetailView.swift; sourceTree = ""; }; E4C766252B72D50500C1DEDA /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; - F83B7CBD2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PrismaStandard+TimeIndex.swift"; sourceTree = ""; }; - F8AF6F992B5F2B1A0011C32D /* AppIcon-NoBG.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon-NoBG.png"; sourceTree = ""; }; F8AF6F9E2B5F35400011C32D /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; F8AF6FA42B5F3AE70011C32D /* EventContextCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventContextCard.swift; sourceTree = ""; }; F8AF6FA82B5F42C40011C32D /* afternoon-en-US.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "afternoon-en-US.json"; sourceTree = ""; }; @@ -199,7 +218,7 @@ F8AF6FAA2B5F42C40011C32D /* morning-en-US.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "morning-en-US.json"; sourceTree = ""; }; F8AF6FAB2B5F42C40011C32D /* mid-day-en-US.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "mid-day-en-US.json"; sourceTree = ""; }; F8AF6FB32B5F6EDC0011C32D /* PrismaModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrismaModule.swift; sourceTree = ""; }; - F8AF6FB52B5F71460011C32D /* PrismaStandard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PrismaStandard+Extension.swift"; sourceTree = ""; }; + F8AF6FB52B5F71460011C32D /* String+HealthKitIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+HealthKitIdentifier.swift"; sourceTree = ""; }; F8AF6FB82B5F72650011C32D /* PrismaStandard+HealthKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PrismaStandard+HealthKit.swift"; sourceTree = ""; }; F8AF6FBB2B5F74EA0011C32D /* PrismaStandard+Questionnaire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PrismaStandard+Questionnaire.swift"; sourceTree = ""; }; F8D35CE12B60A909001A8FA5 /* NotificationPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPermissions.swift; sourceTree = ""; }; @@ -235,7 +254,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 */, @@ -265,14 +283,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 2F4FC8D529EE69BE00BFFE26 /* MockUpload */ = { - isa = PBXGroup; - children = ( - 2F4FC8D629EE69D300BFFE26 /* MockUpload.swift */, - ); - path = MockUpload; - sourceTree = ""; - }; 2FC9759D2978E30800BA99FE /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -315,7 +325,6 @@ 653A255428338800005D4D48 /* Assets.xcassets */, 2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */, 2FE5DC2C29EDD78E004B9AB4 /* ConsentDocument.md */, - F8AF6F992B5F2B1A0011C32D /* AppIcon-NoBG.png */, ); path = Resources; sourceTree = ""; @@ -338,6 +347,7 @@ children = ( 2FE5DC3E29EDD7ED004B9AB4 /* FeatureFlags.swift */, 2FE5DC3F29EDD7EE004B9AB4 /* StorageKeys.swift */, + 2F116D2D2BA7AB9000600A71 /* Constants.swift */, ); path = SharedContext; sourceTree = ""; @@ -345,9 +355,12 @@ 2FE5DC3D29EDD7E4004B9AB4 /* Helper */ = { isa = PBXGroup; children = ( + F8AF6FB52B5F71460011C32D /* String+HealthKitIdentifier.swift */, + 2F116D1D2BA79FAF00600A71 /* Date+ISOFormat.swift */, 2FE5DC4229EDD7F2004B9AB4 /* Binding+Negate.swift */, 2FE5DC4329EDD7F2004B9AB4 /* Bundle+Image.swift */, 2FE5DC4429EDD7F2004B9AB4 /* CodableArray+RawRepresentable.swift */, + 2F116D272BA7A26F00600A71 /* Date+ConstructTimeIndex.swift */, ); path = Helper; sourceTree = ""; @@ -408,8 +421,6 @@ 653A254F283387FE005D4D48 /* Prisma */ = { isa = PBXGroup; children = ( - 5FECE9542B6C9A3E00C06B13 /* PushNotifications */, - AC4A1ED12B69D91D0095D1AE /* PrivacyControls */, 653A2550283387FE005D4D48 /* Prisma.swift */, 2F5E32BC297E05EA003432F8 /* PrismaDelegate.swift */, 2F4E23822989D51F0013F3D9 /* PrismaTestingSetup.swift */, @@ -420,8 +431,9 @@ 2FE5DC2829EDD398004B9AB4 /* Onboarding */, 2FE5DC3B29EDD7D0004B9AB4 /* Schedule */, 2FE5DC2729EDD38D004B9AB4 /* Contacts */, + AC4A1ED12B69D91D0095D1AE /* PrivacyControls */, + 5FECE9542B6C9A3E00C06B13 /* PushNotifications */, 56F6F29E2AB441640022FE5A /* Contributions */, - 2F4FC8D529EE69BE00BFFE26 /* MockUpload */, 2FE5DC3C29EDD7DA004B9AB4 /* SharedContext */, 2FE5DC3D29EDD7E4004B9AB4 /* Helper */, 2FE5DC2D29EDD792004B9AB4 /* Resources */, @@ -467,8 +479,14 @@ isa = PBXGroup; children = ( AC69903D2B6C5A2F00D92970 /* PrivacyModule.swift */, - D8F136C42B85CEED000BA7AE /* DeleteDataView.swift */, + D8F136C42B85CEED000BA7AE /* PrivacyDetailView.swift */, + 2F116D452BA801E100600A71 /* PrivacyDetailViewModel.swift */, + 2F116D362BA7C72700600A71 /* PrivacyDetailHideByTimeRangeSection.swift */, + 2F116D392BA7C79900600A71 /* PrivacyDetailHideByListSection.swift */, D8027E902B90655700BB9466 /* ManageDataView.swift */, + 2F116D3C2BA7CC3C00600A71 /* HKSampleType+UIElements.swift */, + 2F116D3F2BA7F8B800600A71 /* HKSampleType+Encodable.swift */, + 2F116D422BA7F90500600A71 /* HKSampleTypeDecodable.swift */, ); path = PrivacyControls; sourceTree = ""; @@ -495,12 +513,12 @@ F8AF6FB12B5F6EC80011C32D /* Standard */ = { isa = PBXGroup; children = ( + F8AF6FB32B5F6EDC0011C32D /* PrismaModule.swift */, 2FF53D8C2A8729D600042B76 /* PrismaStandard.swift */, - 5FBBD2B52B875DB800B75E9F /* PrismaStandard+PushNotifications.swift */, + 2F116D1F2BA79FE400600A71 /* PrismaStandard+Account.swift */, + 2F116D232BA7A10400600A71 /* PrismaStandard+Consent.swift */, F8AF6FB82B5F72650011C32D /* PrismaStandard+HealthKit.swift */, - F8AF6FB32B5F6EDC0011C32D /* PrismaModule.swift */, - F8AF6FB52B5F71460011C32D /* PrismaStandard+Extension.swift */, - F83B7CBD2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift */, + 5FBBD2B52B875DB800B75E9F /* PrismaStandard+PushNotifications.swift */, F8AF6FBB2B5F74EA0011C32D /* PrismaStandard+Questionnaire.swift */, ); path = Standard; @@ -562,7 +580,6 @@ 2FBD738B2A3BD150004228E7 /* SpeziScheduler */, 2F3D4ABB2A4E7C290068FB2F /* SpeziScheduler */, 2FE5DC8029EDD91D004B9AB4 /* SpeziOnboarding */, - 2FF53D8A2A8725DE00042B76 /* SpeziMockWebService */, 2FB099AE2A875DF100B20952 /* FirebaseAuth */, 2FB099B02A875DF100B20952 /* FirebaseFirestore */, 2FB099B22A875DF100B20952 /* FirebaseFirestoreSwift */, @@ -666,7 +683,6 @@ 2FE5DC9A29EDD9EF004B9AB4 /* XCRemoteSwiftPackageReference "XCTHealthKit" */, 2F3D4ABA2A4E7C290068FB2F /* XCRemoteSwiftPackageReference "SpeziScheduler" */, 97F466E62A76BBEE005DC9B4 /* XCRemoteSwiftPackageReference "SpeziOnboarding" */, - 2FE750CA2A87240100723EAE /* XCRemoteSwiftPackageReference "SpeziMockWebService" */, 2FB099B42A875E2B00B20952 /* XCRemoteSwiftPackageReference "HealthKitOnFHIR" */, 5661551B2AB8384200209B80 /* XCRemoteSwiftPackageReference "swift-package-list" */, ); @@ -696,7 +712,6 @@ buildActionMask = 2147483647; files = ( 2FC3439229EE634B002D773C /* ConsentDocument.md in Resources */, - F8AF6F9A2B5F2B1A0011C32D /* AppIcon-NoBG.png in Resources */, ACB5DFBA2B9F9E76004F28E6 /* GoogleService-Info.plist in Resources */, F8AF6FAF2B5F42C40011C32D /* mid-day-en-US.json in Resources */, F8AF6FAD2B5F42C40011C32D /* end-of-day-en-US.json in Resources */, @@ -749,7 +764,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2F116D2B2BA7A37300600A71 /* Date+ISOFormat.swift in Sources */, + 2F116D322BA7ACE800600A71 /* StorageKeys.swift in Sources */, 5F5ECC492B9F3B5C00B666BC /* NotificationService.swift in Sources */, + 2F116D302BA7ACB800600A71 /* Constants.swift in Sources */, + 2F116D342BA7ACEB00600A71 /* FeatureFlags.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -759,13 +778,15 @@ files = ( 2FE5DC4129EDD7EE004B9AB4 /* StorageKeys.swift in Sources */, 2FE5DCB129EE6107004B9AB4 /* AccountOnboarding.swift in Sources */, - 2F4FC8D729EE69D300BFFE26 /* MockUpload.swift in Sources */, 5FBBD2B62B875DB800B75E9F /* PrismaStandard+PushNotifications.swift in Sources */, 2FE5DC3A29EDD7CA004B9AB4 /* Welcome.swift in Sources */, 2FE5DC3829EDD7CA004B9AB4 /* Features.swift in Sources */, E4C766262B72D50500C1DEDA /* WebView.swift in Sources */, + 2F116D1E2BA79FAF00600A71 /* Date+ISOFormat.swift in Sources */, 2FE5DC4529EDD7F2004B9AB4 /* Binding+Negate.swift in Sources */, 2FC975A82978F11A00BA99FE /* Home.swift in Sources */, + 2F116D242BA7A10400600A71 /* PrismaStandard+Consent.swift in Sources */, + 2F116D462BA801E100600A71 /* PrivacyDetailViewModel.swift in Sources */, 2FE5DC4E29EDD7FA004B9AB4 /* ScheduleView.swift in Sources */, A9DFE8A92ABE551400428242 /* AccountButton.swift in Sources */, 2FE5DC3729EDD7CA004B9AB4 /* OnboardingFlow.swift in Sources */, @@ -776,20 +797,25 @@ A9720E432ABB68CC00872D23 /* AccountSetupHeader.swift in Sources */, 2FE5DC4029EDD7EE004B9AB4 /* FeatureFlags.swift in Sources */, F8AF6FA52B5F3AE70011C32D /* EventContextCard.swift in Sources */, + 2F116D202BA79FE400600A71 /* PrismaStandard+Account.swift in Sources */, 2FE5DC4629EDD7F2004B9AB4 /* Bundle+Image.swift in Sources */, - D8F136C52B85CEED000BA7AE /* DeleteDataView.swift in Sources */, + 2F116D402BA7F8B800600A71 /* HKSampleType+Encodable.swift in Sources */, + D8F136C52B85CEED000BA7AE /* PrivacyDetailView.swift in Sources */, + 2F116D3A2BA7C79900600A71 /* PrivacyDetailHideByListSection.swift in Sources */, F8AF6FB92B5F72650011C32D /* PrismaStandard+HealthKit.swift in Sources */, 2FE5DC4F29EDD7FA004B9AB4 /* EventContext.swift in Sources */, 2FE5DC5029EDD7FA004B9AB4 /* EventContextView.swift in Sources */, - F8AF6FB62B5F71460011C32D /* PrismaStandard+Extension.swift in Sources */, + F8AF6FB62B5F71460011C32D /* String+HealthKitIdentifier.swift in Sources */, 2F4E23832989D51F0013F3D9 /* PrismaTestingSetup.swift in Sources */, - F83B7CBE2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift in Sources */, 2FE5DC5329EDD7FA004B9AB4 /* Bundle+Questionnaire.swift in Sources */, + 2F116D372BA7C72700600A71 /* PrivacyDetailHideByTimeRangeSection.swift in Sources */, 2FE5DC5129EDD7FA004B9AB4 /* PrismaTaskContext.swift in Sources */, F8AF6FBC2B5F74EA0011C32D /* PrismaStandard+Questionnaire.swift in Sources */, + 2F116D3D2BA7CC3C00600A71 /* HKSampleType+UIElements.swift in Sources */, 56F6F2A02AB441930022FE5A /* ContributionsList.swift in Sources */, 5FECE9562B6C9A5F00C06B13 /* PushNotifications.swift in Sources */, 566155292AB8447C00209B80 /* Package+LicenseType.swift in Sources */, + 2F116D432BA7F90500600A71 /* HKSampleTypeDecodable.swift in Sources */, 5680DD392AB8983D004E6D4A /* PackageCell.swift in Sources */, F8D35CE22B60A909001A8FA5 /* NotificationPermissions.swift in Sources */, 2F5E32BD297E05EA003432F8 /* PrismaDelegate.swift in Sources */, @@ -798,9 +824,11 @@ D8027E912B90655700BB9466 /* ManageDataView.swift in Sources */, F8AF6FB42B5F6EDC0011C32D /* PrismaModule.swift in Sources */, AC69903E2B6C5A2F00D92970 /* PrivacyModule.swift in Sources */, + 2F116D282BA7A26F00600A71 /* Date+ConstructTimeIndex.swift in Sources */, 653A2551283387FE005D4D48 /* Prisma.swift in Sources */, 2FE5DC3629EDD7CA004B9AB4 /* HealthKitPermissions.swift in Sources */, 5661552E2AB854C000209B80 /* PackageHelper.swift in Sources */, + 2F116D2E2BA7AB9000600A71 /* Constants.swift in Sources */, 27FA29902A388E9B009CAC45 /* ModalView.swift in Sources */, 2FE5DC2629EDD38A004B9AB4 /* Contacts.swift in Sources */, ); @@ -1543,14 +1571,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"; @@ -1669,11 +1689,6 @@ package = 2FE5DC9A29EDD9EF004B9AB4 /* XCRemoteSwiftPackageReference "XCTHealthKit" */; productName = XCTHealthKit; }; - 2FF53D8A2A8725DE00042B76 /* SpeziMockWebService */ = { - isa = XCSwiftPackageProductDependency; - package = 2FE750CA2A87240100723EAE /* XCRemoteSwiftPackageReference "SpeziMockWebService" */; - productName = SpeziMockWebService; - }; 5661551C2AB8384200209B80 /* SwiftPackageList */ = { isa = XCSwiftPackageProductDependency; package = 5661551B2AB8384200209B80 /* XCRemoteSwiftPackageReference "swift-package-list" */; diff --git a/Prisma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Prisma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 16a74b8..8d11334 100644 --- a/Prisma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Prisma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2744d3b6cb9385cb089a3b7f85f47cd50be0044a6d049ff1f93f530ab4329df2", + "originHash" : "0234cf36595d6a5fc4bf3c54942b170bdbefd26ef609a4d8a651f17037d3face", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/StanfordBDHG/ResearchKit", "state" : { - "revision" : "64512d0a0a5cc3e9d5b3fc5217c54f11d0dc044c", - "version" : "2.2.28" + "revision" : "6b28cdf0d06c3d6e96b5585369968b85deac96e0", + "version" : "2.2.29" } }, { @@ -199,15 +199,6 @@ "version" : "0.5.3" } }, - { - "identity" : "spezimockwebservice", - "kind" : "remoteSourceControl", - "location" : "https://github.com/StanfordSpezi/SpeziMockWebService.git", - "state" : { - "revision" : "b18067d3499e630bbd995ef05a296ef8fdd42528", - "version" : "1.0.0" - } - }, { "identity" : "spezionboarding", "kind" : "remoteSourceControl", @@ -258,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "c8ed701b513cf5177118a175d85fbbbcd707ab41", - "version" : "1.3.0" + "revision" : "46989693916f56d1186bd59ac15124caef896560", + "version" : "1.3.1" } }, { diff --git a/Prisma/Account/AccountSheet.swift b/Prisma/Account/AccountSheet.swift index 1df61dd..3173def 100644 --- a/Prisma/Account/AccountSheet.swift +++ b/Prisma/Account/AccountSheet.swift @@ -11,13 +11,12 @@ import SwiftUI struct AccountSheet: View { - @Environment(\.dismiss) var dismiss - + @Environment(\.dismiss) private var dismiss @Environment(Account.self) private var account - @Environment(\.accountRequired) var accountRequired + @Environment(\.accountRequired) private var accountRequired - @State var isInSetup = false - @State var overviewIsEditing = false + @State private var isInSetup = false + @State private var overviewIsEditing = false var body: some View { @@ -57,7 +56,7 @@ struct AccountSheet: View { } } } - + var closeButton: some ToolbarContent { ToolbarItem(placement: .cancellationAction) { Button("CLOSE") { diff --git a/Prisma/Chat/ChatView.swift b/Prisma/Chat/ChatView.swift index 54dbaa9..8585a87 100644 --- a/Prisma/Chat/ChatView.swift +++ b/Prisma/Chat/ChatView.swift @@ -7,72 +7,79 @@ // import Firebase -import Foundation -import SpeziAccount import SwiftUI -import WebKit + struct ChatView: View { @Binding var presentingAccount: Bool @State private var token: String? + + + private var url: URL? { + guard let token else { + return nil + } + + return Constants.hostname.appending(queryItems: [URLQueryItem(name: token, value: token)]) + } var body: some View { NavigationStack { GeometryReader { geometry in - // Fetch JWT token asynchronously - if let token = token { - if let url = URL(string: "http://localhost:3000?token=\(token)") { // this needs to be sent to the frontend + Group { + if let url { WebView(url: url) - .navigationTitle("Chat") - .frame( - width: geometry.size.width, - height: geometry.size.height - ) } else { - Text("Invalid URL") + VStack(spacing: 16) { + ProgressView() + Text("Loading Chat View") + .foregroundStyle(.secondary) + .font(.caption) + } } - } else { - ProgressView() } + .frame( + width: geometry.size.width, + height: geometry.size.height + ) } - /* - .onChange(of: account.signedIn) { - guard account.signedIn else { - return - } - - Task { - try await self.signInWithFirebase() - } - } - */ - .task { - do { - try await self.getFirebaseIDToken() - } catch { - print("Firebase Auth failed \(error)") + .navigationTitle("Chat") + .task { + await self.getFirebaseIDToken() + } + .toolbar { + if AccountButton.shouldDisplay { + AccountButton(isPresented: $presentingAccount) + } } - } } } + init(presentingAccount: Binding) { self._presentingAccount = presentingAccount } -} - -extension ChatView { - func getFirebaseIDToken() async throws { - token = try await Auth.auth().currentUser?.getIDToken() - print("token is:", token ?? "") + + + private func getFirebaseIDToken() async { + guard !ProcessInfo.processInfo.isPreviewSimulator else { + try? await Task.sleep(for: .seconds(1.0)) + token = "TOKEN" + return + } + + do { + token = try await Auth.auth().currentUser?.getIDToken() + } catch { + print("Firebase Auth failed \(error)") + } } } + #if DEBUG -struct ChatView_Previews: PreviewProvider { - static var previews: some View { - ChatView(presentingAccount: .constant(false)) - } +#Preview { + ChatView(presentingAccount: .constant(false)) } #endif diff --git a/Prisma/Chat/WebView.swift b/Prisma/Chat/WebView.swift index c185599..77ff343 100644 --- a/Prisma/Chat/WebView.swift +++ b/Prisma/Chat/WebView.swift @@ -9,17 +9,28 @@ import SwiftUI import WebKit + struct WebView: UIViewRepresentable { var url: URL - + + func makeUIView(context: Context) -> WKWebView { let webView = WKWebView() webView.scrollView.isScrollEnabled = true return webView } - + func updateUIView(_ uiView: WKWebView, context: Context) { let request = URLRequest(url: url) uiView.load(request) } } + + +#Preview { + guard let stanfordURL = URL(string: "https://stanford.edu") else { + fatalError("Could not construct URL.") + } + + return WebView(url: stanfordURL) +} diff --git a/Prisma/Helper/Date+ConstructTimeIndex.swift b/Prisma/Helper/Date+ConstructTimeIndex.swift new file mode 100644 index 0000000..0f24465 --- /dev/null +++ b/Prisma/Helper/Date+ConstructTimeIndex.swift @@ -0,0 +1,131 @@ +// +// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +extension Date { + static func constructTimeIndex(startDate: Date, endDate: Date) -> [String: Any?] { + let calendar = Calendar.current + // extract the calendar components from the startDate and the endDate + let startComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .timeZone], from: startDate) + let endComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .timeZone], from: endDate) + let isRange = startDate != endDate + + // initialize a dictionary for timeIndex and populate with info extracted above + var timeIndex: [String: Any?] = [ + "range": isRange, + "timezone": startComponents.timeZone?.identifier, + "datetime.start": startDate.toISOFormat(), + "datetime.end": endDate.toISOFormat() + ] + + // passing the timeIndex dictionary by reference so the changes persist + addTimeIndexComponents(&timeIndex, dateComponents: startComponents, suffix: ".start") + addTimeIndexComponents(&timeIndex, dateComponents: endComponents, suffix: ".end") + addTimeIndexRangeComponents(&timeIndex, startComponents: startComponents, endComponents: endComponents) + + return timeIndex + } + + + // populate timeIndex dict with individual components from DateComponents (startComponents for this case) + // "inout" parameter means the argument is passed by reference (dict is modified inside the funct and changes persist) + private static func addTimeIndexComponents(_ timeIndex: inout [String: Any?], dateComponents: DateComponents, suffix: String) { + timeIndex["year" + suffix] = dateComponents.year + timeIndex["month" + suffix] = dateComponents.month + timeIndex["day" + suffix] = dateComponents.day + timeIndex["hour" + suffix] = dateComponents.hour + timeIndex["minute" + suffix] = dateComponents.minute + timeIndex["second" + suffix] = dateComponents.second + timeIndex["dayMinute" + suffix] = calculateDayMinute(hour: dateComponents.hour, minute: dateComponents.minute) + timeIndex["fifteenMinBucket" + suffix] = calculate15MinBucket(hour: dateComponents.hour, minute: dateComponents.minute) + } + + // if the start/end time shows that we have a time RANGE and not a time STAMP + // then add the range-related components to the timeIndex + private static func addTimeIndexRangeComponents( + _ timeIndex: inout [String: Any?], + startComponents: DateComponents, + endComponents: DateComponents + ) { + timeIndex["year.range"] = getRange( + start: startComponents.year, + end: endComponents.year, + maxValue: Int.max + ) + timeIndex["month.range"] = getRange( + start: startComponents.month, + end: endComponents.month, + maxValue: 12, + startValue: 1 // months are 1-indexed + ) + timeIndex["day.range"] = getRange( + start: startComponents.day, + end: endComponents.day, + maxValue: daysInMonth(month: startComponents.month, year: startComponents.year), + startValue: 1 // days are 1-indexed + ) + timeIndex["hour.range"] = getRange( + start: startComponents.hour, + end: endComponents.hour, + maxValue: 23 + ) + timeIndex["dayMinute.range"] = getRange( + start: calculateDayMinute(hour: startComponents.hour, minute: startComponents.minute), + end: calculateDayMinute(hour: endComponents.hour, minute: endComponents.minute), + maxValue: 1439 + ) + timeIndex["fifteenMinBucket.range"] = getRange( + start: calculate15MinBucket(hour: startComponents.hour, minute: startComponents.minute), + end: calculate15MinBucket(hour: endComponents.hour, minute: endComponents.minute), + maxValue: 95 + ) + + // Minute and second ranges are not likely to be accurate since they often will fill the whole range. + // We will also never query on individual minutes or seconds worth of data. + } + + // swiftlint:disable discouraged_optional_collection + // passed the start and end bounds, returns the range in whichever unit passed in + private static func getRange(start: Int?, end: Int?, maxValue: Int?, startValue: Int = 0) -> [Int]? { + guard let startInt = start, let endInt = end, let maxValueInt = maxValue else { + return nil + } + + if startInt <= endInt { + return Array(startInt...endInt) + } else { + return Array(startInt...maxValueInt) + Array(startValue...endInt) + } + } + + private static func daysInMonth(month: Int?, year: Int?) -> Int? { + let dateComponents = DateComponents(year: year, month: month) + let calendar = Calendar.current + guard let date = calendar.date(from: dateComponents), + let range = calendar.range(of: .day, in: .month, for: date) else { + return nil // Provide a default value in case of nil + } + return range.count + } + + private static func calculateDayMinute(hour: Int?, minute: Int?) -> Int? { + guard let hour = hour, let minute = minute else { + return nil + } + return hour * 60 + minute + } + + private static func calculate15MinBucket(hour: Int?, minute: Int?) -> Int? { + guard let hour = hour, let minute = minute else { + return nil + } + return hour * 4 + minute / 15 + } +} diff --git a/Prisma/Standard/PrismaStandard+Extension.swift b/Prisma/Helper/Date+ISOFormat.swift similarity index 62% rename from Prisma/Standard/PrismaStandard+Extension.swift rename to Prisma/Helper/Date+ISOFormat.swift index 6e78a40..6a79cc0 100644 --- a/Prisma/Standard/PrismaStandard+Extension.swift +++ b/Prisma/Helper/Date+ISOFormat.swift @@ -9,23 +9,6 @@ import Foundation -extension String { - /// converts a HKSample Type string representation to a lower cased id. - /// e.g. "HKQuantityTypeIdentifierStepCount" => "stepcount". - var healthKitDescription: String { - if self == "workout" { - return "workout" - } - - let prefixes = ["HKQuantityTypeIdentifier", "HKCategoryTypeIdentifier", "HKCorrelationTypeIdentifier", "HKWorkoutTypeIdentifier"] - for prefix in prefixes where self.hasPrefix(prefix) { - return self.dropFirst(prefix.count).lowercased() - } - // return "unknown" - return self - } -} - extension Date { /// converts Date object to ISO Format string. Can optionally pass in a time zone to convert it to. /// If no timezone is passed, it converts the Date object using the local time zone. diff --git a/Prisma/Helper/String+HealthKitIdentifier.swift b/Prisma/Helper/String+HealthKitIdentifier.swift new file mode 100644 index 0000000..54fc64a --- /dev/null +++ b/Prisma/Helper/String+HealthKitIdentifier.swift @@ -0,0 +1,27 @@ +// +// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +extension String { + /// converts a HKSample Type string representation to a lower cased id. + /// e.g. "HKQuantityTypeIdentifierStepCount" => "stepcount". + var healthKitDescription: String { + if self == "workout" { + return self + } + + let prefixes = ["HKQuantityTypeIdentifier", "HKCategoryTypeIdentifier", "HKCorrelationTypeIdentifier", "HKWorkoutTypeIdentifier"] + for prefix in prefixes where self.hasPrefix(prefix) { + return self.dropFirst(prefix.count).lowercased() + } + // return "unknown" + return self + } +} diff --git a/Prisma/Home.swift b/Prisma/Home.swift index 6c531b5..5691e86 100644 --- a/Prisma/Home.swift +++ b/Prisma/Home.swift @@ -8,7 +8,6 @@ import FirebaseAuth import SpeziAccount -import SpeziMockWebService import SwiftUI @@ -17,19 +16,19 @@ struct HomeView: View { case schedule case chat case contact - case mockUpload case privacy } + static var accountEnabled: Bool { !FeatureFlags.disableFirebase && !FeatureFlags.skipOnboarding } - - + + @AppStorage(StorageKeys.homeTabSelection) private var selectedTab = Tabs.schedule @Environment(PrismaStandard.self) private var standard @State private var presentingAccount = false - + var body: some View { TabView(selection: $selectedTab) { @@ -48,18 +47,11 @@ struct HomeView: View { .tabItem { Label("CONTACTS_TAB_TITLE", systemImage: "person.fill") } - ManageDataView() + ManageDataView(presentingAccount: $presentingAccount) .tag(Tabs.privacy) .tabItem { Label("PRIVACY_CONTROLS_TITLE", systemImage: "gear") } - if FeatureFlags.disableFirebase { - MockUpload(presentingAccount: $presentingAccount) - .tag(Tabs.mockUpload) - .tabItem { - Label("MOCK_WEB_SERVICE_TAB_TITLE", systemImage: "server.rack") - } - } } .sheet(isPresented: $presentingAccount) { AccountSheet() @@ -69,25 +61,7 @@ struct HomeView: View { } .verifyRequiredAccountDetails(Self.accountEnabled) .task { - guard let user = Auth.auth().currentUser else { - print("No signed in user.") - return - } - let accessGroup = "637867499T.edu.stanford.cs342.2024.behavior" - - guard (try? Auth.auth().getStoredUser(forAccessGroup: accessGroup)) == nil else { - print("Access group already shared ...") - return - } - - do { - try Auth.auth().useUserAccessGroup(accessGroup) - try await Auth.auth().updateCurrentUser(user) - } catch let error as NSError { - print("Error changing user access group: %@", error) - // log out the user if fails - try? Auth.auth().signOut() - } + await standard.authorizeAccessGroupForCurrentUser() } } } @@ -102,7 +76,6 @@ struct HomeView: View { return HomeView() .previewWith(standard: PrismaStandard()) { PrismaScheduler() - MockWebService() AccountConfiguration(building: details, active: MockUserIdPasswordAccountService()) } } @@ -112,7 +85,6 @@ struct HomeView: View { return HomeView() .previewWith(standard: PrismaStandard()) { PrismaScheduler() - MockWebService() AccountConfiguration { MockUserIdPasswordAccountService() } diff --git a/Prisma/MockUpload/MockUpload.swift b/Prisma/MockUpload/MockUpload.swift deleted file mode 100644 index 1137adf..0000000 --- a/Prisma/MockUpload/MockUpload.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// This source file is part of the Stanford Prisma Application 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/Prisma/Onboarding/AccountOnboarding.swift b/Prisma/Onboarding/AccountOnboarding.swift index 7bdee77..0250763 100644 --- a/Prisma/Onboarding/AccountOnboarding.swift +++ b/Prisma/Onboarding/AccountOnboarding.swift @@ -14,7 +14,6 @@ import SwiftUI struct AccountOnboarding: View { @Environment(Account.self) private var account - @Environment(PrismaStandard.self) private var standard @Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath @@ -22,30 +21,11 @@ struct AccountOnboarding: View { var body: some View { AccountSetup { _ in Task { - guard let user = Auth.auth().currentUser else { - print("No signed in user.") - return - } - let accessGroup = "637867499T.edu.stanford.cs342.2024.behavior" - - guard (try? Auth.auth().getStoredUser(forAccessGroup: accessGroup)) == nil else { - print("Access group already shared ...") - return - } - - do { - try Auth.auth().useUserAccessGroup(accessGroup) - try await Auth.auth().updateCurrentUser(user) - } catch let error as NSError { - print("Error changing user access group: %@", error) - // log out the user if fails - try? Auth.auth().signOut() - } - + await standard.authorizeAccessGroupForCurrentUser() + await standard.setAccountTimestamp() // Placing the nextStep() call inside this task will ensure that the sheet dismiss animation is // played till the end before we navigate to the next step. - await standard.setAccountTimestamp() onboardingNavigationPath.nextStep() } } header: { @@ -67,11 +47,11 @@ struct AccountOnboarding: View { OnboardingStack { AccountOnboarding() } - .previewWith { - AccountConfiguration { - MockUserIdPasswordAccountService() + .previewWith(standard: PrismaStandard()) { + AccountConfiguration { + MockUserIdPasswordAccountService() + } } - } } #Preview("Account Onboarding") { @@ -82,8 +62,8 @@ struct AccountOnboarding: View { return OnboardingStack { AccountOnboarding() } - .previewWith { - AccountConfiguration(building: details, active: MockUserIdPasswordAccountService()) - } + .previewWith(standard: PrismaStandard()) { + AccountConfiguration(building: details, active: MockUserIdPasswordAccountService()) + } } #endif diff --git a/Prisma/Onboarding/Features.swift b/Prisma/Onboarding/Features.swift index adabec1..3c41e91 100644 --- a/Prisma/Onboarding/Features.swift +++ b/Prisma/Onboarding/Features.swift @@ -34,10 +34,10 @@ struct Features: View { Spacer() .frame(height: 10) } - .frame(minHeight: geometry.size.height) + .frame(minHeight: geometry.size.height) } } - .padding(24) + .padding(24) } } diff --git a/Prisma/Onboarding/NotificationPermissions.swift b/Prisma/Onboarding/NotificationPermissions.swift index 3240f70..f3c4b5d 100644 --- a/Prisma/Onboarding/NotificationPermissions.swift +++ b/Prisma/Onboarding/NotificationPermissions.swift @@ -71,8 +71,9 @@ struct NotificationPermissions: View { OnboardingStack { NotificationPermissions() } - .previewWith { + .previewWith(standard: PrismaStandard()) { PrismaScheduler() + PrismaPushNotifications() } } #endif diff --git a/Prisma/Onboarding/OnboardingFlow.swift b/Prisma/Onboarding/OnboardingFlow.swift index eb43e80..ed29073 100644 --- a/Prisma/Onboarding/OnboardingFlow.swift +++ b/Prisma/Onboarding/OnboardingFlow.swift @@ -35,11 +35,9 @@ struct OnboardingFlow: View { OnboardingStack(onboardingFlowComplete: $completedOnboardingFlow) { Welcome() Features() - if !FeatureFlags.disableFirebase { AccountOnboarding() } - if HKHealthStore.isHealthDataAvailable() && !healthKitAuthorization { HealthKitPermissions() } diff --git a/Prisma/Onboarding/Welcome.swift b/Prisma/Onboarding/Welcome.swift index c728f0f..0e0ebd5 100644 --- a/Prisma/Onboarding/Welcome.swift +++ b/Prisma/Onboarding/Welcome.swift @@ -13,17 +13,18 @@ import SwiftUI struct Welcome: View { @Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath + var body: some View { Group { GeometryReader { geometry in ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .center) { title - Image(uiImage: Bundle.main.image(withName: "AppIcon-NoBG", fileExtension: "png")) - .resizable() - .scaledToFit() - .frame(width: 0.7 * geometry.size.width) - .accessibilityHidden(true) + Image(.appIconNoBackground) + .resizable() + .scaledToFit() + .frame(width: 0.7 * geometry.size.width) + .accessibilityHidden(true) Spacer() description Spacer() @@ -33,15 +34,15 @@ struct Welcome: View { Spacer() .frame(height: 10) } - .frame(minHeight: geometry.size.height) + .frame(minHeight: geometry.size.height) } } - .padding(24) + .padding(24) } } var title: some View { - Text("WELCOME_TITLE") + Text("PRISMA") .font(.system(size: 60)) .fontWeight(.bold) .fontDesign(.rounded) diff --git a/Prisma/PrismaDelegate.swift b/Prisma/PrismaDelegate.swift index 357e031..c583fe5 100644 --- a/Prisma/PrismaDelegate.swift +++ b/Prisma/PrismaDelegate.swift @@ -14,14 +14,14 @@ import SpeziFirebaseAccount import SpeziFirebaseStorage import SpeziFirestore import SpeziHealthKit -import SpeziMockWebService import SpeziOnboarding import SpeziScheduler import SwiftUI class PrismaDelegate: SpeziAppDelegate { - private let sampleList = [ + // https://developer.apple.com/documentation/healthkit/data_types#2939032 + static let healthKitSampleTypes = [ // Activity HKQuantityType(.stepCount), HKQuantityType(.distanceWalkingRunning), @@ -46,6 +46,7 @@ class PrismaDelegate: SpeziAppDelegate { HKWorkoutType.workoutType() ] + override var configuration: Configuration { Configuration(standard: PrismaStandard()) { if !FeatureFlags.disableFirebase { @@ -53,7 +54,6 @@ class PrismaDelegate: SpeziAppDelegate { .requires(\.userId), .requires(\.name) ]) - if FeatureFlags.useFirebaseEmulator { FirebaseAccountConfiguration( authenticationMethods: [.emailAndPassword, .signInWithApple], @@ -68,17 +68,14 @@ class PrismaDelegate: SpeziAppDelegate { } else { FirebaseStorageConfiguration() } - } else { - MockWebService() } - if HKHealthStore.isHealthDataAvailable() { healthKit } PrismaScheduler() OnboardingDataSource() PrismaPushNotifications() - PrivacyModule(sampleTypeList: sampleList) + PrivacyModule(sampleTypes: PrismaDelegate.healthKitSampleTypes) } } @@ -99,8 +96,7 @@ class PrismaDelegate: SpeziAppDelegate { private var healthKit: HealthKit { HealthKit { CollectSamples( - // https://developer.apple.com/documentation/healthkit/data_types#2939032 - Set(sampleList), + Set(PrismaDelegate.healthKitSampleTypes), /// predicate to request data from one month in the past to present. predicate: HKQuery.predicateForSamples( withStart: Calendar.current.date(byAdding: .month, value: -1, to: .now), diff --git a/Prisma/PrivacyControls/DeleteDataView.swift b/Prisma/PrivacyControls/DeleteDataView.swift deleted file mode 100644 index 75df697..0000000 --- a/Prisma/PrivacyControls/DeleteDataView.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project -// -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT -// - -// -// DeleteDataView.swift -// Prisma -// -// Created by Evelyn Hur, Caroline Tran on 2/20/24. -// - -import FirebaseFirestore -import Foundation -import Spezi -import SpeziHealthKit -import SwiftUI - - -struct DeleteDataView: View { - @Environment(PrivacyModule.self) private var privacyModule - @Environment(PrismaStandard.self) private var standard - // category identifier is passed into DeleteDataView from ManageDataView - var categoryIdentifier: String - - // NEXT STEPS: timeArrayStatic will be replaced by timestampsArray which is read in from firestore using the categoryIdentifier and getPath - @State private var timeArrayStatic: [String] = [] - // var timeArray = getLastTimestamps(quantityType: "stepcount") - @State private var crossedOutTimestamps: [String: Bool] = [:] - @State private var customHideStartDate = Date() - @State private var customHideEndDate = Date() - @State private var customRangeTimestamps: [String] = [] - - // state variable for the category toggle - @State private var isCategoryToggleOn = false - - var body: some View { - Form { - descriptionSection - toggleSection - hideByCustomRangeSection - hideByTimeSection - } - .navigationTitle(privacyModule.identifierInfo[categoryIdentifier]?.uiString ?? "Identifier Title Not Found") - .onAppear { - Task { - timeArrayStatic = await standard.fetchTop10RecentTimeStamps(selectedTypeIdentifier: categoryIdentifier) - } - } - } - - var descriptionSection: some View { - Section(header: Text("About")) { - Text(privacyModule.identifierInfo[categoryIdentifier]?.description ?? "Missing Description.") - } - } - - var toggleSection: some View { - Section(header: Text("Allow Data Upload")) { - Toggle(privacyModule.identifierInfo[categoryIdentifier]?.uiString ?? "Missing UI Type String ", isOn: Binding( - get: { - // get the current enable status for the toggle - // default to a disabled toggle if the value is missing - privacyModule.identifierInfo[categoryIdentifier]?.enabledBool ?? false - }, - set: { newValue in - // Update dict with new toggle status, signal to other views about dict change - privacyModule.updateAndSignalOnChange(identifierString: categoryIdentifier, newToggleVal: newValue) - } - )) - } - } - - var hideByCustomRangeSection: some View { - Section(header: Text("Hide Data by Custom Range")) { - VStack { - DatePicker("Start date", selection: $customHideStartDate, displayedComponents: [.date, .hourAndMinute]) - DatePicker("End date", selection: $customHideEndDate, displayedComponents: [.date, .hourAndMinute]) - - Divider() - - Button("Hide") { - let startDateString = customHideStartDate.toISOFormat() - let endDateString = customHideEndDate.toISOFormat() - Task { - customRangeTimestamps = await standard.fetchCustomRangeTimeStamps( - selectedTypeIdentifier: categoryIdentifier, - startDate: startDateString, - endDate: endDateString - ) - } - switchHiddenInBackend(identifier: categoryIdentifier, timestamps: customRangeTimestamps, alwaysHide: true) - } -// .frame(maxWidth: .infinity) // Make the button take full width - } - } - } - - var hideByTimeSection: some View { - Section(header: Text("Hide Data by Recent Timestamps")) { - timeStampsDisplay - } - } - - var timeStampsDisplay: some View { - ForEach(timeArrayStatic, id: \.self) { timestamp in - HStack { - Image(systemName: crossedOutTimestamps[timestamp, default: false] ? "eye.slash" : "eye") - .accessibilityLabel(crossedOutTimestamps[timestamp, default: false] ? "Hide Timestamp" : "Show Timestamp") - .onTapGesture { - switchHiddenInBackend(identifier: categoryIdentifier, timestamps: [timestamp], alwaysHide: false) - crossedOutTimestamps[timestamp]?.toggle() ?? (crossedOutTimestamps[timestamp] = true) - } - Text(timestamp) - } - .foregroundColor(crossedOutTimestamps[timestamp, default: false] ? .gray : .black) - .opacity(crossedOutTimestamps[timestamp, default: false] ? 0.5 : 1.0) - } - } - - func switchHiddenInBackend(identifier: String, timestamps: [String], alwaysHide: Bool) { - for timestamp in timestamps { - Task { - await standard.switchHideFlag(selectedTypeIdentifier: identifier, timestamp: timestamp, alwaysHide: alwaysHide) - } - } - } -} - -struct DeleteDataView_Previews: PreviewProvider { - static var previews: some View { - DeleteDataView(categoryIdentifier: "Example Preview: DeleteDataView") - } -} diff --git a/Prisma/PrivacyControls/HKSampleType+Encodable.swift b/Prisma/PrivacyControls/HKSampleType+Encodable.swift new file mode 100644 index 0000000..23a95c4 --- /dev/null +++ b/Prisma/PrivacyControls/HKSampleType+Encodable.swift @@ -0,0 +1,17 @@ +// +// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import HealthKit + + +extension HKSampleType: Encodable { + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.identifier) + } +} diff --git a/Prisma/PrivacyControls/HKSampleType+UIElements.swift b/Prisma/PrivacyControls/HKSampleType+UIElements.swift new file mode 100644 index 0000000..51e5f5e --- /dev/null +++ b/Prisma/PrivacyControls/HKSampleType+UIElements.swift @@ -0,0 +1,135 @@ +// +// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import HealthKit + + +extension HKSampleType { + var systemImage: String { + switch self.identifier { + case HealthKit.HKQuantityType(.stepCount).identifier: + "shoeprints.fill" + case HealthKit.HKQuantityType(.distanceWalkingRunning).identifier: + "figure.walk" + case HealthKit.HKQuantityType(.basalEnergyBurned).identifier: + "fork.knife.circle" + case HealthKit.HKQuantityType(.activeEnergyBurned).identifier: + "flame" + case HealthKit.HKQuantityType(.flightsClimbed).identifier: + "figure.stairs" + case HealthKit.HKQuantityType(.appleExerciseTime).identifier: + "figure.run.square.stack" + case HealthKit.HKQuantityType(.appleMoveTime).identifier: + "figure.cooldown" + case HealthKit.HKQuantityType(.appleStandTime).identifier: + "figure.stand" + case HealthKit.HKQuantityType(.heartRate).identifier: + "waveform.path.ecg" + case HealthKit.HKQuantityType(.restingHeartRate).identifier: + "arrow.down.heart" + case HealthKit.HKQuantityType(.heartRateVariabilitySDNN).identifier: + "chart.line.uptrend.xyaxis" + case HealthKit.HKQuantityType(.walkingHeartRateAverage).identifier: + "figure.walk.motion" + case HealthKit.HKQuantityType(.oxygenSaturation).identifier: + "drop.degreesign" + case HealthKit.HKQuantityType(.respiratoryRate).identifier: + "lungs.fill" + case HealthKit.HKQuantityType(.bodyTemperature).identifier: + "medical.thermometer" + case HealthKit.HKCategoryType(.sleepAnalysis).identifier: + "bed.double.fill" + case HKWorkoutType.workoutType().identifier: + "figure.strengthtraining.functional" + default: + "questionmark.circle" + } + } + + var title: LocalizedStringResource { + switch self.identifier { + case HealthKit.HKQuantityType(.stepCount).identifier: + "Step Count" + case HealthKit.HKQuantityType(.distanceWalkingRunning).identifier: + "Distance Walking Running" + case HealthKit.HKQuantityType(.basalEnergyBurned).identifier: + "Resting Energy Burned" + case HealthKit.HKQuantityType(.activeEnergyBurned).identifier: + "Active Energy Burned" + case HealthKit.HKQuantityType(.flightsClimbed).identifier: + "Flights Climbed" + case HealthKit.HKQuantityType(.appleExerciseTime).identifier: + "Exercise Time" + case HealthKit.HKQuantityType(.appleMoveTime).identifier: + "Move Time" + case HealthKit.HKQuantityType(.appleStandTime).identifier: + "Stand Time" + case HealthKit.HKQuantityType(.heartRate).identifier: + "Heart Rate" + case HealthKit.HKQuantityType(.restingHeartRate).identifier: + "Resting Heart Rate" + case HealthKit.HKQuantityType(.heartRateVariabilitySDNN).identifier: + "Heart Rate Variability" + case HealthKit.HKQuantityType(.walkingHeartRateAverage).identifier: + "Walking Heart Rate Average" + case HealthKit.HKQuantityType(.oxygenSaturation).identifier: + "Oxygen Saturation" + case HealthKit.HKQuantityType(.respiratoryRate).identifier: + "Respiratory Rate" + case HealthKit.HKQuantityType(.bodyTemperature).identifier: + "Body Temperature" + case HealthKit.HKCategoryType(.sleepAnalysis).identifier: + "Sleep Analysis" + case HKWorkoutType.workoutType().identifier: + "Workout" + default: + "Unknown HealthKit Type" + } + } + + var extendedDescription: LocalizedStringResource { + switch self.identifier { + case HealthKit.HKQuantityType(.stepCount).identifier: + "STEP_COUNT_DESCRIPTION" + case HealthKit.HKQuantityType(.distanceWalkingRunning).identifier: + "DISTANCE_WALKING_DESCRIPTION" + case HealthKit.HKQuantityType(.basalEnergyBurned).identifier: + "BASAL_ENERGY_BURNED_DESCRIPTION" + case HealthKit.HKQuantityType(.activeEnergyBurned).identifier: + "ACTIVE_ENERGY_BURNED_DESCRIPTION" + case HealthKit.HKQuantityType(.flightsClimbed).identifier: + "FLIGHTS_CLIMBED_DESCRIPTION" + case HealthKit.HKQuantityType(.appleExerciseTime).identifier: + "APPLE_EXERCISE_TIME_DESCRIPTION" + case HealthKit.HKQuantityType(.appleMoveTime).identifier: + "APPLE_MOVE_TIME_DESCRIPTION" + case HealthKit.HKQuantityType(.appleStandTime).identifier: + "APPLE_STAND_TIME_DESCRIPTION" + case HealthKit.HKQuantityType(.heartRate).identifier: + "HEART_RATE_DESCRIPTION" + case HealthKit.HKQuantityType(.restingHeartRate).identifier: + "RESTING_HEART_RATE_DESCRIPTION" + case HealthKit.HKQuantityType(.heartRateVariabilitySDNN).identifier: + "HEART_RATE_VARIABILITY_SDNN_DESCRIPTION" + case HealthKit.HKQuantityType(.walkingHeartRateAverage).identifier: + "WALKING_HEART_RATE_AVERAGE_DESCRIPTION" + case HealthKit.HKQuantityType(.oxygenSaturation).identifier: + "OXYGEN_SATURATION_DESCRIPTION" + case HealthKit.HKQuantityType(.respiratoryRate).identifier: + "RESPIRATORY_RATE_DESCRIPTION" + case HealthKit.HKQuantityType(.bodyTemperature).identifier: + "BODY_TEMPERATURE_DESCRIPTION" + case HealthKit.HKCategoryType(.sleepAnalysis).identifier: + "SLEEP_ANALYSIS_DESCRIPTION" + case HKWorkoutType.workoutType().identifier: + "WORKOUT_DESCRIPTION" + default: + "Unknown HealthKit Type" + } + } +} diff --git a/Prisma/PrivacyControls/HKSampleTypeDecodable.swift b/Prisma/PrivacyControls/HKSampleTypeDecodable.swift new file mode 100644 index 0000000..a15ac89 --- /dev/null +++ b/Prisma/PrivacyControls/HKSampleTypeDecodable.swift @@ -0,0 +1,35 @@ +// +// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import HealthKit + + +struct HKSampleTypeDecodable: Decodable, Hashable { + var sampleType: HKSampleType? + + + init(_ sampleType: HKSampleType) { + self.sampleType = sampleType + } + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let identifier = try container.decode(String.self) + + // Attempt to create the specific HKSampleType based on the identifier + if let quantityType = HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier(rawValue: identifier)) { + sampleType = quantityType + } else if let categoryType = HKCategoryType.categoryType(forIdentifier: HKCategoryTypeIdentifier(rawValue: identifier)) { + sampleType = categoryType + } else if identifier == HKWorkoutTypeIdentifier { + sampleType = HKWorkoutType.workoutType() + } else { + sampleType = nil + } + } +} diff --git a/Prisma/PrivacyControls/ManageDataView.swift b/Prisma/PrivacyControls/ManageDataView.swift index 4d969d3..2c39d4d 100644 --- a/Prisma/PrivacyControls/ManageDataView.swift +++ b/Prisma/PrivacyControls/ManageDataView.swift @@ -6,53 +6,59 @@ // SPDX-License-Identifier: MIT // -// -// ManageDataView.swift -// Prisma -// -// Created by Evelyn Hur on 2/28/24. -// - import SwiftUI struct ManageDataView: View { - @EnvironmentObject var privacyModule: PrivacyModule - + @Environment(PrivacyModule.self) var privacyModule + @Environment(PrismaStandard.self) var prismaStandard + + @Binding var presentingAccount: Bool + + var body: some View { NavigationView { List { - ForEach(privacyModule.sortedSampleIdentifiers, id: \.self) { sampleIdentifier in + ForEach(privacyModule.sampleTypes, id: \.identifier) { sampleIdentifier in NavigationLink( - destination: DeleteDataView( - categoryIdentifier: privacyModule.identifierInfo[sampleIdentifier]?.identifier ?? "missing identifier string" - ) + destination: PrivacyDetailView(sampleIdentifier, standard: prismaStandard) ) { - HStack(alignment: .center, spacing: 10) { - Image(systemName: privacyModule.identifierInfo[sampleIdentifier]?.iconName ?? "missing icon name") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 35, height: 35) - .accessibility(label: Text("accessibility text temp")) - VStack(alignment: .leading, spacing: 4) { - Text(privacyModule.identifierInfo[sampleIdentifier]?.uiString ?? "missing ui identifier string") - .font(.headline) - // assume a default value of false if there is a nil value in enabledBool - Text((privacyModule.identifierInfo[sampleIdentifier]?.enabledBool ?? false) ? "Enabled" : "Disabled") - .font(.subheadline) - .foregroundColor(.gray) - } + HStack { + Label( + title: { + Text(sampleIdentifier.title) + }, + icon: { + Image(systemName: sampleIdentifier.systemImage) + .accessibilityHidden(true) + } + ) + Spacer() + Text("\(privacyModule.collectDataTypes[sampleIdentifier, default: false] ? Text("Enabled") : Text("Disabled"))") + .font(.subheadline) + .foregroundColor(.secondary) } } } } - .navigationTitle("Manage Data") - } - .onReceive(privacyModule.identifierInfoPublisher) { _ in - self.privacyModule.objectWillChange.send() + .navigationTitle("Manage Data") + .toolbar { + if AccountButton.shouldDisplay { + AccountButton(isPresented: $presentingAccount) + } + } } } + + + init(presentingAccount: Binding) { + self._presentingAccount = presentingAccount + } } + #Preview { - ManageDataView() + ManageDataView(presentingAccount: .constant(false)) + .previewWith { + PrivacyModule(sampleTypes: PrismaDelegate.healthKitSampleTypes) + } } diff --git a/Prisma/PrivacyControls/PrivacyDetailHideByListSection.swift b/Prisma/PrivacyControls/PrivacyDetailHideByListSection.swift new file mode 100644 index 0000000..297cdb3 --- /dev/null +++ b/Prisma/PrivacyControls/PrivacyDetailHideByListSection.swift @@ -0,0 +1,106 @@ +// +// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import FirebaseFirestore +import SpeziHealthKit +import SpeziViews +import SwiftUI + + +private struct PrivacyDetailHideByListRow: View { + @Environment(PrismaStandard.self) private var standard + + private let sampleType: HKSampleType + private let recentSample: QueryDocumentSnapshot + + @State private var markedAsHidden: Bool + @State private var processing = false + + + var body: some View { + HStack { + Label( + title: { + Text(recentSample.documentID) + }, + icon: { + Image(systemName: markedAsHidden ? "eye.slash" : "eye") + .accessibilityLabel(markedAsHidden ? "Hide Timestamp" : "Show Timestamp") + .foregroundStyle(markedAsHidden ? Color.gray : Color.accentColor) + } + ) + .opacity(processing ? 0.5 : 1.0) + Spacer() + if processing { + ProgressView() + } + } + .onTapGesture { + guard !processing else { + return + } + + Task { + do { + processing = true + try await standard.toggleHideFlag( + sampleType: sampleType, + documentId: recentSample.documentID, + alwaysHide: false + ) + markedAsHidden.toggle() + } catch { + print("Could not toggle privacy control ...") + } + processing = false + } + } + } + + + init(sampleType: HKSampleType, recentSample: QueryDocumentSnapshot) { + self.sampleType = sampleType + self.recentSample = recentSample + self._markedAsHidden = State(wrappedValue: (recentSample.data()["hideFlag"] as? Bool) ?? false) + } +} + + +struct PrivacyDetailHideByListSection: View { + @Environment(PrismaStandard.self) private var standard + @Environment(PrivacyDetailViewModel.self) private var privacyDetailViewModel + + private let sampleType: HKSampleType + + + var body: some View { + Section(header: Text("Hide Data by Custom Range")) { + ForEach(privacyDetailViewModel.recentSamples, id: \.documentID) { recentSample in + PrivacyDetailHideByListRow(sampleType: sampleType, recentSample: recentSample) + } + } + .task { + await privacyDetailViewModel.reload() + } + } + + + init(sampleType: HKSampleType) { + self.sampleType = sampleType + } +} + + +#Preview { + List { + PrivacyDetailHideByListSection(sampleType: HKQuantityType(.stepCount)) + .previewWith(standard: PrismaStandard()) { + PrivacyModule(sampleTypes: PrismaDelegate.healthKitSampleTypes) + } + } +} diff --git a/Prisma/PrivacyControls/PrivacyDetailHideByTimeRangeSection.swift b/Prisma/PrivacyControls/PrivacyDetailHideByTimeRangeSection.swift new file mode 100644 index 0000000..b2ea593 --- /dev/null +++ b/Prisma/PrivacyControls/PrivacyDetailHideByTimeRangeSection.swift @@ -0,0 +1,59 @@ +// +// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziHealthKit +import SpeziViews +import SwiftUI + + +struct PrivacyDetailHideByTimeRangeSection: View { + @Environment(PrismaStandard.self) private var standard + @Environment(PrivacyDetailViewModel.self) private var privacyDetailViewModel + + let sampleType: HKSampleType + + @State private var startDate = Date() + @State private var endDate = Date() + + + var body: some View { + Section(header: Text("Hide Data by Custom Range")) { + VStack { + DatePicker("Start date", selection: $startDate, displayedComponents: [.date, .hourAndMinute]) + DatePicker("End date", selection: $endDate, displayedComponents: [.date, .hourAndMinute]) + Divider() + AsyncButton( + action: { + await standard.hideSamples(sampleType: sampleType, startDate: startDate, endDate: endDate) + await privacyDetailViewModel.reload() + }, + label: { + Text("Hide") + .frame(maxWidth: .infinity, minHeight: 30) + } + ) + .buttonStyle(.borderedProminent) + } + } + } + + + init(sampleType: HKSampleType) { + self.sampleType = sampleType + } +} + + +#Preview { + List { + PrivacyDetailHideByTimeRangeSection(sampleType: HKQuantityType(.stepCount)) + .previewWith(standard: PrismaStandard()) { + PrivacyModule(sampleTypes: PrismaDelegate.healthKitSampleTypes) + } + } +} diff --git a/Prisma/PrivacyControls/PrivacyDetailView.swift b/Prisma/PrivacyControls/PrivacyDetailView.swift new file mode 100644 index 0000000..fd90a1d --- /dev/null +++ b/Prisma/PrivacyControls/PrivacyDetailView.swift @@ -0,0 +1,64 @@ +// +// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziHealthKit +import SpeziViews +import SwiftUI + + +struct PrivacyDetailView: View { + @Environment(PrivacyModule.self) var privacyModule + @State var privacyDetailViewModel: PrivacyDetailViewModel + let sampleType: HKSampleType + + + var body: some View { + Form { + descriptionSection + toggleSection + PrivacyDetailHideByTimeRangeSection(sampleType: sampleType) + PrivacyDetailHideByListSection(sampleType: sampleType) + } + .environment(privacyDetailViewModel) + .navigationTitle(sampleType.title.localizedString()) + } + + @ViewBuilder private var descriptionSection: some View { + Section(header: Text("About")) { + Text(sampleType.extendedDescription) + } + } + + @ViewBuilder private var toggleSection: some View { + Section(header: Text("Allow Data Upload")) { + Toggle(isOn: privacyModule.binding(for: sampleType)) { + Text(sampleType.title) + } + } + } + + + init(_ sampleType: HKSampleType, standard: PrismaStandard) { + self.sampleType = sampleType + self._privacyDetailViewModel = State( + wrappedValue: PrivacyDetailViewModel(sampleType: sampleType, standard: standard) + ) + } +} + + +#Preview { + let standard = PrismaStandard() + + return NavigationStack { + PrivacyDetailView(HKQuantityType(.stepCount), standard: standard) + .previewWith(standard: standard) { + PrivacyModule(sampleTypes: PrismaDelegate.healthKitSampleTypes) + } + } +} diff --git a/Prisma/PrivacyControls/PrivacyDetailViewModel.swift b/Prisma/PrivacyControls/PrivacyDetailViewModel.swift new file mode 100644 index 0000000..f220d7e --- /dev/null +++ b/Prisma/PrivacyControls/PrivacyDetailViewModel.swift @@ -0,0 +1,29 @@ +// +// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import FirebaseFirestore +import HealthKit + + +@Observable +class PrivacyDetailViewModel { + let sampleType: HKSampleType + let standard: PrismaStandard + var recentSamples: [QueryDocumentSnapshot] = [] + + + init(sampleType: HKSampleType, standard: PrismaStandard) { + self.sampleType = sampleType + self.standard = standard + } + + + func reload() async { + recentSamples = await standard.fetchRecentSamples(for: sampleType) + } +} diff --git a/Prisma/PrivacyControls/PrivacyModule.swift b/Prisma/PrivacyControls/PrivacyModule.swift index f9c38b9..e02b0b2 100644 --- a/Prisma/PrivacyControls/PrivacyModule.swift +++ b/Prisma/PrivacyControls/PrivacyModule.swift @@ -6,213 +6,50 @@ // SPDX-License-Identifier: MIT // - -// PrivacyControls.swift -// Prisma -// -// Created by Dhruv Naik on 2/1/24. -// Edited by Evelyn Hur and Caroline on 2/28/24. - -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT - import Combine import Foundation import HealthKit import Spezi +import SpeziLocalStorage import SwiftUI -public class PrivacyModule: Module, EnvironmentAccessible, ObservableObject { - // when there are changes to the identifierInfo dictionary - // (e.g. the user changes the enable/disabled toggle for the category type in DeleteDataView), - // we want to signal the ManageDataView that listens for this signal and refreshes its view with new info - public struct DataCategoryItem { - var uiString: String - var iconName: String - var enabledBool: Bool - var description: LocalizedStringKey - var identifier: String - } +@Observable +class PrivacyModule: Module, EnvironmentAccessible { + #warning("We should consider storing the privacy module preferences in Firebase.") + @ObservationIgnored @Dependency private var localStorage: LocalStorage - @StandardActor var standard: PrismaStandard - - var sortedSampleIdentifiers: [String] - var sampleTypeList: [HKSampleType] - var toggleMapUpdated: [String: Bool] = [:] - - // expose the publisher so other views can subscribe to changes in identifierInfo dict - public var identifierInfoPublisher: AnyPublisher { - // expose the publisher without revealing its exact type - // outside code only knows that its dealing with AnyPublisher - identifierInfoSubject.eraseToAnyPublisher() as AnyPublisher + let sampleTypes: [HKSampleType] + var collectDataTypes: [HKSampleType: Bool] = [:] { + didSet { + try? localStorage.store(collectDataTypes, storageKey: StorageKeys.privacyControlsSampleTypes) + } } - // create a Combine publisher that sends signal to subscribers each time identifierInfo is changed - private var identifierInfoSubject = PassthroughSubject() - - // Dictionary mapping string identifier to all UI necessary information - // If the enabledBool is ever changed for any items in this dict, subscribing views should refresh - @Published public var identifierInfo: [String: DataCategoryItem] = [ - "stepcount": DataCategoryItem( - uiString: "Step Count", - iconName: "shoeprints.fill", - enabledBool: true, - description: "STEP_COUNT_DESCRIPTION", - identifier: "stepcount" - ), - "distancewalkingrunning": DataCategoryItem( - uiString: "Distance Walking Running", - iconName: "figure.walk", - enabledBool: true, - description: "distance walking description", - identifier: "distancewalkingrunning" - ), - "basalenergyburned": DataCategoryItem( - uiString: "Resting Energy Burned", - iconName: "fork.knife.circle", - enabledBool: true, - description: "BASAL_ENERGY_BURNED_DESCRIPTION", - identifier: "basalenergyburned" - ), - "activeenergyburned": DataCategoryItem( - uiString: "Active Energy Burned", - iconName: "flame", - enabledBool: true, - description: "ACTIVE_ENERGY_BURNED_DESCRIPTION", - identifier: "activeenergyburned" - ), - "flightsclimbed": DataCategoryItem( - uiString: "Flights Climbed", - iconName: "figure.stairs", - enabledBool: true, - description: "FLIGHTS_CLIMBED_DESCRIPTION", - identifier: "flightsclimbed" - ), - "appleexercisetime": DataCategoryItem( - uiString: "Exercise Time", - iconName: "figure.run.square.stack", - enabledBool: true, - description: "APPLE_EXERCISE_TIME_DESCRIPTION", - identifier: "appleexercisetime" - ), - "applemovetime": DataCategoryItem( - uiString: "Move Time", - iconName: "figure.cooldown", - enabledBool: true, - description: "APPLE_MOVE_TIME_DESCRIPTION", - identifier: "applemovetime" - ), - "applestandtime": DataCategoryItem( - uiString: "Stand Time", - iconName: "figure.stand", - enabledBool: true, - description: "APPLE_STAND_TIME_DESCRIPTION", - identifier: "applestandtime" - ), - "heartrate": DataCategoryItem( - uiString: "Heart Rate", - iconName: "waveform.path.ecg", - enabledBool: true, - description: "HEART_RATE_DESCRIPTION", - identifier: "heartrate" - ), - "restingheartrate": DataCategoryItem( - uiString: "Resting Heart Rate", - iconName: "arrow.down.heart", - enabledBool: true, - description: "RESTING_HEART_RATE_DESCRIPTION", - identifier: "restingheartrate" - ), - "heartratevariabilitysdnn": DataCategoryItem( - uiString: "Heart Rate Variability", - iconName: "chart.line.uptrend.xyaxis", - enabledBool: true, - description: "HEART_RATE_VARIABILITY_SDNN_DESCRIPTION", - identifier: "heartratevariabilitysdnn" - ), - "walkingheartrateaverage": DataCategoryItem( - uiString: "Walking Heart Rate Average", - iconName: "figure.walk.motion", - enabledBool: true, - description: "WALKING_HEART_RATE_AVERAGE_DESCRIPTION", - identifier: "walkingheartrateaverage" - ), - "oxygensaturation": DataCategoryItem( - uiString: "Oxygen Saturation", - iconName: "drop.degreesign", - enabledBool: true, - description: "OXYGEN_SATURATION_DESCRIPTION", - identifier: "oxygensaturation" - ), - "respiratoryrate": DataCategoryItem( - uiString: "Respiratory Rate", - iconName: "lungs.fill", - enabledBool: true, - description: "RESPIRATORY_RATE_DESCRIPTION", - identifier: "respiratoryrate" - ), - "bodytemperature": DataCategoryItem( - uiString: "Body Temperature", - iconName: "medical.thermometer", - enabledBool: true, - description: "BODY_TEMPERATURE_DESCRIPTION", - identifier: "bodytemperature" - ), - "sleepanalysis": DataCategoryItem( - uiString: "Sleep Analysis", - iconName: "bed.double.fill", - enabledBool: true, - description: "SLEEP_ANALYSIS_DESCRIPTION", - identifier: "sleepanalysis" - ), - "workout": DataCategoryItem( - uiString: "Workout", - iconName: "figure.strengthtraining.functional", - enabledBool: true, - description: "workout description", - identifier: "workout" - ) - ] - - public required init(sampleTypeList: [HKSampleType]) { - self.sampleTypeList = sampleTypeList - - var sampleTypeIdentifiers: [String] = [] - for sampleType in sampleTypeList { - if sampleType == HKWorkoutType.workoutType() { - sampleTypeIdentifiers.append("workout") - } else { - sampleTypeIdentifiers.append(sampleType.identifier.healthKitDescription) - } - } - sortedSampleIdentifiers = sampleTypeIdentifiers.sorted() - print(sortedSampleIdentifiers) + required init(sampleTypes: [HKSampleType]) { + self.sampleTypes = sampleTypes } - // this function is called by DeleteDataView to signal a change each time it changes a bool value - public func updateAndSignalOnChange(identifierString: String, newToggleVal: Bool) { - identifierInfo[identifierString]?.enabledBool = newToggleVal - print("Updated toggle status for \(identifierString) to: \(String(describing: identifierInfo[identifierString]?.enabledBool))") - identifierInfoSubject.send() - print("Change detected in identifierInfo dictionary, signal sent to all subscriber views.") - } - public func configure() { - Task { - toggleMapUpdated = await getHKSampleTypeMappings() + func configure() { + let storedCollectDataTypes = ( + try? localStorage.read([HKSampleTypeDecodable: Bool].self, storageKey: StorageKeys.privacyControlsSampleTypes) + ) ?? [:] + + for sampleType in sampleTypes { + collectDataTypes[sampleType] = storedCollectDataTypes[HKSampleTypeDecodable(sampleType)] ?? true } } - public func getHKSampleTypeMappings() async -> [String: Bool] { - var toggleMapUpdated: [String: Bool] = [:] - - for sampleType in sampleTypeList { - let identifier = await standard.getSampleIdentifierFromHKSampleType(sampleType: sampleType) - toggleMapUpdated[identifier ?? "Unidentified Sample Type"] = true - } - return toggleMapUpdated + func binding(for sampleType: HKSampleType) -> Binding { + Binding( + get: { + self.collectDataTypes[sampleType, default: false] + }, + set: { newValue in + self.collectDataTypes[sampleType] = newValue + } + ) } } diff --git a/Prisma/PushNotifications/PushNotifications.swift b/Prisma/PushNotifications/PushNotifications.swift index 1193b09..b075265 100644 --- a/Prisma/PushNotifications/PushNotifications.swift +++ b/Prisma/PushNotifications/PushNotifications.swift @@ -5,12 +5,6 @@ // // SPDX-License-Identifier: MIT // -// This file implements functions necessary for push notifications to be implemented within the Prisma application. -// Includes methods for monitoring token refresh, using methods from the PrismaStandard to upload them to a user's -// collection in Firebase. -// -// Created by Bryant Jimenez on 2/1/24. -// import Firebase import FirebaseCore @@ -20,6 +14,9 @@ import SpeziFirebaseConfiguration import SwiftUI +/// This file implements functions necessary for push notifications to be implemented within the Prisma application. +/// Includes methods for monitoring token refresh, using methods from the PrismaStandard to upload them to a user's +/// collection in Firebase. class PrismaPushNotifications: NSObject, Module, NotificationHandler, NotificationTokenHandler, MessagingDelegate, UNUserNotificationCenterDelegate, EnvironmentAccessible { @Application(\.registerRemoteNotifications) var registerRemoteNotifications @@ -58,9 +55,7 @@ class PrismaPushNotifications: NSObject, Module, NotificationHandler, Notificati func receiveIncomingNotification(_ notification: UNNotification) async -> UNNotificationPresentationOptions? { let receivedTimestamp = Date().toISOFormat(timezone: TimeZone(abbreviation: "UTC")) if let sentTimestamp = notification.request.content.userInfo["sent_timestamp"] as? String { - Task { - await standard.addNotificationReceivedTimestamp(timeSent: sentTimestamp, timeReceived: receivedTimestamp) - } + await standard.addNotificationReceivedTimestamp(timeSent: sentTimestamp, timeReceived: receivedTimestamp) } else { print("Sent timestamp is not a string or is nil") } @@ -71,9 +66,7 @@ class PrismaPushNotifications: NSObject, Module, NotificationHandler, Notificati func receiveRemoteNotification(_ remoteNotification: [AnyHashable: Any]) async -> BackgroundFetchResult { let receivedTimestamp = Date().toISOFormat(timezone: TimeZone(abbreviation: "UTC")) if let sentTimestamp = remoteNotification["sent_timestamp"] as? String { - Task { - await standard.addNotificationReceivedTimestamp(timeSent: sentTimestamp, timeReceived: receivedTimestamp) - } + await standard.addNotificationReceivedTimestamp(timeSent: sentTimestamp, timeReceived: receivedTimestamp) } else { print("Sent timestamp is not a string or is nil") } @@ -90,7 +83,6 @@ class PrismaPushNotifications: NSObject, Module, NotificationHandler, Notificati func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { // Update the token in Firestore: // The standard is an actor, which protects against data races and conforms to - // immutable data practice. Therefore we get into new asynchronous context and execute Task { await standard.storeToken(token: fcmToken) diff --git a/Prisma/Resources/AppIcon.png b/Prisma/Resources/AppIcon.png deleted file mode 100644 index 7cac992..0000000 Binary files a/Prisma/Resources/AppIcon.png and /dev/null differ diff --git a/Prisma/Resources/AppIcon.png.license b/Prisma/Resources/AppIcon.png.license deleted file mode 100644 index 79fa51c..0000000 --- a/Prisma/Resources/AppIcon.png.license +++ /dev/null @@ -1,6 +0,0 @@ - -This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project - -SPDX-FileCopyrightText: 2023 Stanford University - -SPDX-License-Identifier: MIT diff --git a/Prisma/Resources/AppIcon-NoBG.png b/Prisma/Resources/Assets.xcassets/App Icon No Background.imageset/AppIcon-NoBG.png similarity index 100% rename from Prisma/Resources/AppIcon-NoBG.png rename to Prisma/Resources/Assets.xcassets/App Icon No Background.imageset/AppIcon-NoBG.png diff --git a/Prisma/Resources/AppIcon-NoBG.png.license b/Prisma/Resources/Assets.xcassets/App Icon No Background.imageset/AppIcon-NoBG.png.license similarity index 100% rename from Prisma/Resources/AppIcon-NoBG.png.license rename to Prisma/Resources/Assets.xcassets/App Icon No Background.imageset/AppIcon-NoBG.png.license diff --git a/Prisma/Resources/Assets.xcassets/AppIconNoBG.appiconset/Contents.json b/Prisma/Resources/Assets.xcassets/App Icon No Background.imageset/Contents.json similarity index 62% rename from Prisma/Resources/Assets.xcassets/AppIconNoBG.appiconset/Contents.json rename to Prisma/Resources/Assets.xcassets/App Icon No Background.imageset/Contents.json index 3bac7ac..a0bc057 100644 --- a/Prisma/Resources/Assets.xcassets/AppIconNoBG.appiconset/Contents.json +++ b/Prisma/Resources/Assets.xcassets/App Icon No Background.imageset/Contents.json @@ -2,9 +2,7 @@ "images" : [ { "filename" : "AppIcon-NoBG.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" + "idiom" : "universal" } ], "info" : { diff --git a/Prisma/Resources/Assets.xcassets/AppIconNoBG.appiconset/Contents.json.license b/Prisma/Resources/Assets.xcassets/App Icon No Background.imageset/Contents.json.license similarity index 100% rename from Prisma/Resources/Assets.xcassets/AppIconNoBG.appiconset/Contents.json.license rename to Prisma/Resources/Assets.xcassets/App Icon No Background.imageset/Contents.json.license diff --git a/Prisma/Resources/Assets.xcassets/AppIconNoBG.appiconset/AppIcon-NoBG.png b/Prisma/Resources/Assets.xcassets/AppIconNoBG.appiconset/AppIcon-NoBG.png deleted file mode 100644 index f7b6b8f..0000000 Binary files a/Prisma/Resources/Assets.xcassets/AppIconNoBG.appiconset/AppIcon-NoBG.png and /dev/null differ diff --git a/Prisma/Resources/Assets.xcassets/AppIconNoBG.appiconset/AppIcon-NoBG.png.license b/Prisma/Resources/Assets.xcassets/AppIconNoBG.appiconset/AppIcon-NoBG.png.license deleted file mode 100644 index 79fa51c..0000000 --- a/Prisma/Resources/Assets.xcassets/AppIconNoBG.appiconset/AppIcon-NoBG.png.license +++ /dev/null @@ -1,6 +0,0 @@ - -This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project - -SPDX-FileCopyrightText: 2023 Stanford University - -SPDX-License-Identifier: MIT diff --git a/Prisma/Resources/Localizable.xcstrings b/Prisma/Resources/Localizable.xcstrings index ea23ca5..be25f2b 100644 --- a/Prisma/Resources/Localizable.xcstrings +++ b/Prisma/Resources/Localizable.xcstrings @@ -4,10 +4,10 @@ "" : { }, - "About" : { + "%@" : { }, - "accessibility text temp" : { + "About" : { }, "ACCOUNT_NEXT" : { @@ -61,6 +61,9 @@ } } } + }, + "Active Energy Burned" : { + }, "ACTIVE_ENERGY_BURNED" : { "extractionState" : "manual" @@ -141,6 +144,9 @@ } } } + }, + "Body Temperature" : { + }, "BODY_TEMPERATURE_DESCRIPTION" : { "extractionState" : "manual", @@ -230,11 +236,28 @@ "Disabled" : { }, - "distance walking description" : { + "Distance Walking Running" : { + }, + "DISTANCE_WALKING_DESCRIPTION" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A quantity sample type that measures the distance the user has moved by walking or running." + } + } + } }, "EMMA_BRUNSKILL_BIO" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Emma Brunskill is an associate tenured professor in the Computer Science Department at Stanford University. Prof. Brunskill's goal is to create AI systems that learn from few samples to robustly make good decisions, motivated by our applications to healthcare and education. Their lab is part of the Stanford AI Lab, the Stanford Statistical ML group, and AI Safety @Stanford. Prof. Brunskill was previously an assistant professor at Carnegie Mellon University, whose work has been honored by early faculty career awards (National Science Foundation, Office of Naval Research, Microsoft Research (1 of 7 worldwide) ). Their work, together with their amazing lab members, has received several best research paper nominations (CHI, EDMx3) and awards (UAI, RLDM, ITS). They are privileged to serve on the International Machine Learning Society (which coordinates ICML) Board, the Khan Academy Research Advisory Board, the Stanford Faculty Women's Forum Steering Committee, and previously served on the Women in Machine Learning (WIML) board." + } + } + } }, "Enabled" : { @@ -261,6 +284,9 @@ } } } + }, + "Exercise Time" : { + }, "Expired at %@" : { @@ -358,6 +384,9 @@ } } } + }, + "Flights Climbed" : { + }, "FLIGHTS_CLIMBED_DESCRIPTION" : { "extractionState" : "manual", @@ -410,6 +439,12 @@ } } } + }, + "Heart Rate" : { + + }, + "Heart Rate Variability" : { + }, "HEART_RATE_DESCRIPTION" : { "extractionState" : "manual", @@ -438,15 +473,9 @@ }, "Hide Data by Custom Range" : { - }, - "Hide Data by Recent Timestamps" : { - }, "Hide Timestamp" : { - }, - "Invalid URL" : { - }, "JAMES_LANDAY_BIO" : { "localizations" : { @@ -468,42 +497,38 @@ } } }, - "Manage Data" : { + "Loading Chat View" : { }, - "MATTHEW_JOERKE_BIO" : { + "Manage Data" : { }, - "MID_DAY_SURVEY_DESCRIPTION" : { + "MATTHEW_JOERKE_BIO" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Please fill out the Mid-day Survey every day between 11AM-2PM." + "value" : "Matthew is a fourth year PhD student at Stanford University, where he is co-advised by Prof. Emma Brunskill and Prof. James Landay. His research is at the intersection of human-computer interaction and machine learning, with an emphasis on studying and designing technology to promote wellbeing." } } } }, - "MID_DAY_SURVEY_TITLE" : { + "MID_DAY_SURVEY_DESCRIPTION" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Mid-day Survey" + "value" : "Please fill out the Mid-day Survey every day between 11AM-2PM." } } } }, - "Missing Description." : { - - }, - "MOCK_WEB_SERVICE_TAB_TITLE" : { - "comment" : "MARK: - Mock Upload Data Storage Provider", + "MID_DAY_SURVEY_TITLE" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Mock Web Service" + "value" : "Mid-day Survey" } } } @@ -527,6 +552,9 @@ } } } + }, + "Move Time" : { + }, "NO_SURVEYS_MESSAGE" : { "extractionState" : "manual", @@ -579,6 +607,9 @@ } } } + }, + "Oxygen Saturation" : { + }, "OXYGEN_SATURATION_DESCRIPTION" : { "extractionState" : "manual", @@ -590,6 +621,9 @@ } } } + }, + "PRISMA" : { + }, "PRIVACY_CONTROLS_TITLE" : { "comment" : "MARK: - Privacy Controls", @@ -622,6 +656,9 @@ } } } + }, + "Respiratory Rate" : { + }, "RESPIRATORY_RATE_DESCRIPTION" : { "extractionState" : "manual", @@ -633,9 +670,23 @@ } } } + }, + "Resting Energy Burned" : { + + }, + "Resting Heart Rate" : { + }, "RESTING_HEART_RATE_DESCRIPTION" : { - "extractionState" : "manual" + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A quantity sample type that measures the user’s resting heart rate." + } + } + } }, "SCHEDULE_LIST_TITLE" : { "localizations" : { @@ -673,6 +724,9 @@ }, "Show Timestamp" : { + }, + "Sleep Analysis" : { + }, "SLEEP_ANALYSIS_DESCRIPTION" : { "extractionState" : "manual", @@ -684,9 +738,15 @@ } } } + }, + "Stand Time" : { + }, "Start date" : { + }, + "Step Count" : { + }, "STEP_COUNT_DESCRIPTION" : { "extractionState" : "manual", @@ -719,41 +779,14 @@ } } }, - "TASK_LABEL %@" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Task: %@" - } - } - } - }, - "TASK_SOCIAL_SUPPORT_QUESTIONNAIRE_DESCRIPTION" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Please fill out the Social Support Questionnaire every day." - } - } - } - }, - "TASK_SOCIAL_SUPPORT_QUESTIONNAIRE_TITLE" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Social Support Questionnaire" - } - } - } + "Unknown HealthKit Type" : { + }, "Upcoming notification" : { + }, + "Walking Heart Rate Average" : { + }, "WALKING_HEART_RATE_AVERAGE_DESCRIPTION" : { "extractionState" : "manual", @@ -786,22 +819,21 @@ } } }, - "WELCOME_TITLE" : { - "comment" : "MARK: Welcome", + "Welcome!" : { + + }, + "Workout" : { + + }, + "WORKOUT_DESCRIPTION" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "PRISMA" + "value" : "A type that identifies samples that store information about a workout." } } } - }, - "Welcome!" : { - - }, - "workout description" : { - } }, "version" : "1.0" diff --git a/Prisma/Resources/SocialSupportQuestionnaire.json.license b/Prisma/Resources/SocialSupportQuestionnaire.json.license deleted file mode 100644 index 79fa51c..0000000 --- a/Prisma/Resources/SocialSupportQuestionnaire.json.license +++ /dev/null @@ -1,6 +0,0 @@ - -This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project - -SPDX-FileCopyrightText: 2023 Stanford University - -SPDX-License-Identifier: MIT diff --git a/Prisma/SharedContext/Constants.swift b/Prisma/SharedContext/Constants.swift new file mode 100644 index 0000000..1295409 --- /dev/null +++ b/Prisma/SharedContext/Constants.swift @@ -0,0 +1,30 @@ +// +// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +enum Constants { + static var hostname: URL = { + let hostname: URL? + if FeatureFlags.useFirebaseEmulator { + hostname = URL(string: "http://localhost:3000") + } else { + #warning("Needs to be replaced with the deployed PRISMA web service & frontend.") + hostname = URL(string: "https://prisma.stanford.edu") + } + + guard let hostname else { + fatalError("Could not construct Constants.hostname") + } + + return hostname + }() + + static let keyChainGroup = "637867499T.edu.stanford.cs342.2024.behavior" +} diff --git a/Prisma/SharedContext/FeatureFlags.swift b/Prisma/SharedContext/FeatureFlags.swift index 60558f9..5239ff0 100644 --- a/Prisma/SharedContext/FeatureFlags.swift +++ b/Prisma/SharedContext/FeatureFlags.swift @@ -14,7 +14,7 @@ enum FeatureFlags { static let showOnboarding = CommandLine.arguments.contains("--showOnboarding") /// Disables the Firebase interactions, including the login/sign-up step and the Firebase Firestore upload. static let disableFirebase = CommandLine.arguments.contains("--disableFirebase") -#if targetEnvironment(simulator) + #if targetEnvironment(simulator) /// Defines if the application should connect to the local firebase emulator. Always set to true when using the iOS simulator. static let useFirebaseEmulator = true #else diff --git a/Prisma/SharedContext/StorageKeys.swift b/Prisma/SharedContext/StorageKeys.swift index dca4566..56f8cc5 100644 --- a/Prisma/SharedContext/StorageKeys.swift +++ b/Prisma/SharedContext/StorageKeys.swift @@ -17,4 +17,9 @@ enum StorageKeys { // MARK: - Home /// The currently selected home tab. static let homeTabSelection = "home.tabselection" + + + // MARK: - Privacy Controls + /// The privacy control settings of a user which sample types should be collected + static let privacyControlsSampleTypes = "privacy.controlsampletypes" } diff --git a/Prisma/Standard/PrismaStandard+Account.swift b/Prisma/Standard/PrismaStandard+Account.swift new file mode 100644 index 0000000..18a0469 --- /dev/null +++ b/Prisma/Standard/PrismaStandard+Account.swift @@ -0,0 +1,149 @@ +// +// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import FirebaseAuth +import FirebaseFirestore +import Foundation +import SpeziAccount +import SpeziFirebaseAccountStorage + + +extension PrismaStandard: AccountStorageConstraint { + var userDocumentReference: DocumentReference { + get async throws { + guard let details = await account.details else { + throw PrismaStandardError.userNotAuthenticatedYet + } + + return Self.userCollection.document(details.accountId) + } + } + + /// The firestore path for a given `Module`. + /// - Parameter module: The `Module` that is requested. + func getPath(module: PrismaModule) async throws -> String { + let accountId: String + if FeatureFlags.disableFirebase { + accountId = "USER_ID" + } else { + guard let details = await account.details else { + throw PrismaStandardError.userNotAuthenticatedYet + } + accountId = details.accountId + } + + /// the "MODULE/SUBTYPE" string. + var moduleText: String + + switch module { + case .questionnaire(let type): + // Questionnaire responses + moduleText = "\(module.description)/\(type)" + case .health(let type): + // HealthKit observations + moduleText = "\(module.description)/\(type.healthKitDescription)" + case .notifications(let type): + // notifications for user, type either "logs" or "schedule" + moduleText = "\(module.description)/data/\(type)" + } + // studies/STUDY_ID/users/USER_ID/MODULE_NAME/SUB_TYPE/... + return "studies/\(PrismaStandard.STUDYID)/users/\(accountId)/\(moduleText)/" + } + + + // MARK: - Account State Handling + /// Authorizes access to the Prisma keychain access group for the currently signed-in user. + /// + /// If the current user is signed in, this function authorizes their access to the Prisma notifications keychain access group identifier. + /// If the user is not signed in, or if an error occurs during the authorization process, appropriate error handling is performed, and the user may be logged out. + /// + /// - Parameters: + /// - user: The current user object. + /// - accessGroup: The identifier of the access group to authorize. + /// + /// - Throws: An error if an issue occurs during the authorization process. + func authorizeAccessGroupForCurrentUser() async { + guard let user = Auth.auth().currentUser else { + print("No signed in user.") + return + } + + guard (try? Auth.auth().getStoredUser(forAccessGroup: Constants.keyChainGroup)) == nil else { + print("Access group already shared ...") + return + } + + do { + try Auth.auth().useUserAccessGroup(Constants.keyChainGroup) + try await Auth.auth().updateCurrentUser(user) + } catch let error as NSError { + print("Error changing user access group: %@", error) + // log out the user if fails + try? Auth.auth().signOut() + } + } + + func deletedAccount() async throws { + // delete all user associated data + do { + try await userDocumentReference.delete() + } catch { + logger.error("Could not delete user document: \(error)") + } + } + + + // MARK: - Account Details + func create(_ identifier: AdditionalRecordId, _ details: SignupDetails) async throws { + guard let accountStorage else { + preconditionFailure("Account Storage was requested although not enabled in current configuration.") + } + try await accountStorage.create(identifier, details) + } + + func setAccountTimestamp() async { + // Add a "created_at" timestamp to the newly created user document + let timestamp = Timestamp(date: Date()) + do { + try await self.userDocumentReference.setData([ + "created_at": timestamp + ], merge: true) + print("Added timestamp to user document") + } catch { + print("Error updating document: \(error)") + } + } + + func load(_ identifier: AdditionalRecordId, _ keys: [any AccountKey.Type]) async throws -> PartialAccountDetails { + guard let accountStorage else { + preconditionFailure("Account Storage was requested although not enabled in current configuration.") + } + return try await accountStorage.load(identifier, keys) + } + + func modify(_ identifier: AdditionalRecordId, _ modifications: AccountModifications) async throws { + guard let accountStorage else { + preconditionFailure("Account Storage was requested although not enabled in current configuration.") + } + try await accountStorage.modify(identifier, modifications) + } + + func clear(_ identifier: AdditionalRecordId) async { + guard let accountStorage else { + preconditionFailure("Account Storage was requested although not enabled in current configuration.") + } + await accountStorage.clear(identifier) + } + + func delete(_ identifier: AdditionalRecordId) async throws { + guard let accountStorage else { + preconditionFailure("Account Storage was requested although not enabled in current configuration.") + } + try await accountStorage.delete(identifier) + } +} diff --git a/Prisma/Standard/PrismaStandard+Consent.swift b/Prisma/Standard/PrismaStandard+Consent.swift new file mode 100644 index 0000000..75ea3c7 --- /dev/null +++ b/Prisma/Standard/PrismaStandard+Consent.swift @@ -0,0 +1,58 @@ +// +// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import FirebaseStorage +import PDFKit +import SpeziOnboarding + + +extension PrismaStandard: OnboardingConstraint { + private var userBucketReference: StorageReference { + get async throws { + guard let details = await account.details else { + throw PrismaStandardError.userNotAuthenticatedYet + } + + return Storage.storage().reference().child("users/\(details.accountId)") + } + } + + /// Stores the given consent form in the user's document directory with a unique timestamped filename. + /// + /// - Parameter consent: The consent form's data to be stored as a `PDFDocument`. + func store(consent: PDFDocument) async { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd_HHmmss" + let dateString = formatter.string(from: Date()) + + guard !FeatureFlags.disableFirebase else { + guard let basePath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + logger.error("Could not create path for writing consent form to user document directory.") + return + } + + let filePath = basePath.appending(path: "consentForm_\(dateString).pdf") + consent.write(to: filePath) + + return + } + + do { + guard let consentData = consent.dataRepresentation() else { + logger.error("Could not store consent form.") + return + } + + let metadata = StorageMetadata() + metadata.contentType = "application/pdf" + _ = try await userBucketReference.child("consent/\(dateString).pdf").putDataAsync(consentData, metadata: metadata) + } catch { + logger.error("Could not store consent form: \(error)") + } + } +} diff --git a/Prisma/Standard/PrismaStandard+HealthKit.swift b/Prisma/Standard/PrismaStandard+HealthKit.swift index 20ea16b..0b023cd 100644 --- a/Prisma/Standard/PrismaStandard+HealthKit.swift +++ b/Prisma/Standard/PrismaStandard+HealthKit.swift @@ -12,63 +12,33 @@ import ModelsR4 import SpeziFirestore import SpeziHealthKit -extension PrismaStandard { - func getSampleIdentifier(sample: HKSample) -> String? { - switch sample { - case let quantitySample as HKQuantitySample: - return quantitySample.quantityType.identifier - case let categorySample as HKCategorySample: - return categorySample.categoryType.identifier - case is HKWorkout: - // return "\lcal(workout.workoutActivityType)" - return "workout" - // Add more cases for other HKSample subclasses if needed - default: - return nil - } - } - /// Takes in HKSampleType and returns the corresponding identifier string - /// - /// - Parameters: - /// - sampleType: HKSampleType to find identifier for - /// - Returns: A string for the sample type identifier. - public func getSampleIdentifierFromHKSampleType(sampleType: HKSampleType) -> String? { - if let quantityType = sampleType as? HKQuantityType { - return quantityType.identifier - } else if let categoryType = sampleType as? HKCategoryType { - return categoryType.identifier - } else if sampleType is HKWorkoutType { - return "workout" +extension PrismaStandard: HealthKitConstraint { + /// Adds a new `HKSample` to the Firestore. + /// - Parameter response: The `HKSample` that should be added. + func add(sample: HKSample) async { + guard let collectDataTypes = privacyModule?.collectDataTypes else { + return } - // Default case for other HKSampleTypes - else { - return "Unknown Sample Type" + + // Only upload types that the user gave permission for. + guard collectDataTypes[sample.sampleType] ?? false else { + return } - } - - func writeToFirestore(sample: HKSample, identifier: String) async { + // convert the startDate of the HKSample to local time - let timeIndex = constructTimeIndex(startDate: sample.startDate, endDate: sample.endDate) + let timeIndex = Date.constructTimeIndex(startDate: sample.startDate, endDate: sample.endDate) let effectiveTimestamp = sample.startDate.toISOFormat() let path: String // path = HEALTH_KIT_PATH/raw/YYYY-MM-DDThh:mm:ss.mss do { - path = try await getPath(module: .health(identifier)) + "raw/\(effectiveTimestamp)" + path = try await getPath(module: .health(sample.sampleType.identifier)) + "raw/\(effectiveTimestamp)" } catch { print("Failed to define path: \(error.localizedDescription)") return } - 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: path, body: jsonRepresentation) - return - } - // try push to Firestore. do { let deviceName = sample.sourceRevision.source.name @@ -83,45 +53,25 @@ extension PrismaStandard { firestoreResource["datetimeStart"] = effectiveTimestamp try await Firestore.firestore().document(path).setData(firestoreResource) } catch { - print("Failed to set data in Firestore: \(error.localizedDescription)") + logger.warning("Failed to set data in Firestore: \(error.localizedDescription)") } } - /// Adds a new `HKSample` to the Firestore. - /// - Parameter response: The `HKSample` that should be added. - func add(sample: HKSample) async { - guard let privacyModule = privacyModule else { - return - } - let toggleMap = await privacyModule.getHKSampleTypeMappings() - - let identifier: String - if let id = getSampleIdentifier(sample: sample) { - identifier = id - } else { - print("Failed to upload HealtHkit sample. Unknown sample type: \(sample)") - return - } - if !(toggleMap[identifier] ?? false) { - return - } - await writeToFirestore(sample: sample, identifier: identifier) - } + func remove(sample: HKDeletedObject) async {} - func remove(sample: HKDeletedObject) async { } - func switchHideFlag(selectedTypeIdentifier: String, timestamp: String, alwaysHide: Bool) async { + func toggleHideFlag(sampleType: HKSampleType, documentId: String, alwaysHide: Bool) async throws { let firestore = Firestore.firestore() let path: String do { // call getPath to get the path for this user, up until this specific quantityType - path = try await getPath(module: .health(selectedTypeIdentifier)) + "raw/\(timestamp)" - print("selectedindentifier:" + selectedTypeIdentifier) - print("PATH FROM GET PATH: " + path) + path = try await getPath(module: .health(sampleType.identifier)) + "raw/\(documentId)" + logger.debug("Selected identifier: \(sampleType.identifier)") + logger.debug("Path from getPath: \(path)") } catch { - print("Failed to define path: \(error.localizedDescription)") - return + logger.error("Failed to define path: \(error.localizedDescription)") + throw error } do { @@ -133,75 +83,73 @@ extension PrismaStandard { if alwaysHide { // If alwaysHide is true, always set hideFlag to true regardless of original value try await document.setData(["hideFlag": true], merge: true) - print("AlwaysHide is enabled; set hideFlag to true.") + logger.debug("AlwaysHide is enabled; set hideFlag to true.") } else { // Toggle hideFlag if alwaysHide is not true try await document.setData(["hideFlag": !hideFlagExists], merge: true) - print("Toggled hideFlag to \(!hideFlagExists).") + logger.debug("Toggled hideFlag to \(!hideFlagExists).") } } else { // If hideFlag does not exist, create it and set to true try await document.setData(["hideFlag": true], merge: true) - print("hideFlag was missing; set to true.") + logger.debug("hideFlag was missing; set to true.") } } catch { - print("Failed to set data in Firestore: \(error.localizedDescription)") + logger.error("Failed to set data in Firestore: \(error.localizedDescription)") + throw error } } - func fetchTop10RecentTimeStamps(selectedTypeIdentifier: String) async -> [String] { - let firestore = Firestore.firestore() - let path: String - var timestampsArr: [String] = [] - + func fetchRecentSamples(for sampleType: HKSampleType, limit: Int = 50) async -> [QueryDocumentSnapshot] { + guard !ProcessInfo.processInfo.isPreviewSimulator else { + return [] + } + do { - path = try await getPath(module: .health(selectedTypeIdentifier)) + "raw/" - print("Selected identifier: " + selectedTypeIdentifier) - print("Path from getPath: " + path) + let path = try await getPath(module: .health(sampleType.identifier)) + "raw/" + logger.debug("Selected identifier: \(sampleType.identifier)") + logger.debug("Path from getPath: \(path)") - let querySnapshot = try await firestore.collection(path) - .order(by: "datetimeStart", descending: true) - .limit(to: 10) + #warning("The logic should ideally not be based on the issued date but rather datetimeStart once this is reflected in the mock data.") + let querySnapshot = try await Firestore + .firestore() + .collection(path) + .order(by: "issued", descending: true) + .limit(to: limit) .getDocuments() - - for document in querySnapshot.documents { - timestampsArr.append(document.documentID) - } - return timestampsArr + return querySnapshot.documents } catch { - print("Failed to fetch documents or define path: \(error.localizedDescription)") + logger.error("Failed to fetch documents or define path: \(error.localizedDescription)") return [] } } // Fetches timestamp based on documentID date - func fetchCustomRangeTimeStamps(selectedTypeIdentifier: String, startDate: String, endDate: String) async -> [String] { - let firestore = Firestore.firestore() - let path: String - var timestampsArr: [String] = [] - + func hideSamples(sampleType: HKSampleType, startDate: Date, endDate: Date) async { do { - path = try await getPath(module: .health(selectedTypeIdentifier)) + "raw/" - print("Selected identifier: " + selectedTypeIdentifier) - print("Path from getPath: " + path) + let path = try await getPath(module: .health(sampleType.identifier)) + "raw/" + logger.debug("Selected identifier: \(sampleType.identifier)") + logger.debug("Path from getPath: \(path)") - let querySnapshot = try await firestore.collection(path) - .whereField("datetimeStart", isGreaterThanOrEqualTo: startDate) - .whereField("datetimeStart", isLessThanOrEqualTo: endDate) + #warning("The logic should ideally not be based on the issued date but rather datetimeStart once this is reflected in the mock data.") + let querySnapshot = try await Firestore + .firestore() + .collection(path) + .whereField("issued", isGreaterThanOrEqualTo: startDate.toISOFormat()) + .whereField("issued", isLessThanOrEqualTo: endDate.toISOFormat()) .getDocuments() + #warning("This execution is slow. We should have a clound function or backend endpoint for this.") for document in querySnapshot.documents { - timestampsArr.append(document.documentID) + try await toggleHideFlag(sampleType: sampleType, documentId: document.documentID, alwaysHide: true) } - return timestampsArr } catch { if let firestoreError = error as? FirestoreError { - print("Error fetching documents: \(firestoreError.localizedDescription)") + logger.error("Error fetching documents: \(firestoreError.localizedDescription)") } else { - print("Unexpected error: \(error.localizedDescription)") + logger.error("Unexpected error: \(error.localizedDescription)") } - return [] } } } diff --git a/Prisma/Standard/PrismaStandard+PushNotifications.swift b/Prisma/Standard/PrismaStandard+PushNotifications.swift index afd5c6a..6786a38 100644 --- a/Prisma/Standard/PrismaStandard+PushNotifications.swift +++ b/Prisma/Standard/PrismaStandard+PushNotifications.swift @@ -5,13 +5,41 @@ // // SPDX-License-Identifier: MIT // -// Created by Bryant Jimenez on 2/22/24. -// import FirebaseFirestore import Foundation + extension PrismaStandard { + /// Stores the user device APNs Token in the user's document directory. + /// + /// - Parameter token: The specific device token to be stored as a `String`. + func storeToken(token: String?) async { + struct FirebaseDocumentTokenData: Codable { + let apnsToken: String? + } + + do { + let userDocument = try await userDocumentReference.getDocument() + if userDocument.exists { + let existingTokenData = try await userDocumentReference.getDocument(as: FirebaseDocumentTokenData.self) + + // Unwrap existingTokenData.apns_token and provide a default value if it's nil + if existingTokenData.apnsToken != nil { + if existingTokenData.apnsToken != token { + try await userDocumentReference.updateData(["apnsToken": token ?? ""]) + } + } + // user currently doesn't have apns token, must initialize a new field + else { + try await userDocumentReference.setData(["apnsToken": token ?? ""], merge: true) + } + } + } catch { + print("Error retrieving user document: \(error)") + } + } + /// Stores the timestamp when a notification was received by /// the user's device to the specific notification document. /// diff --git a/Prisma/Standard/PrismaStandard+Questionnaire.swift b/Prisma/Standard/PrismaStandard+Questionnaire.swift index 6480bae..56cd946 100644 --- a/Prisma/Standard/PrismaStandard+Questionnaire.swift +++ b/Prisma/Standard/PrismaStandard+Questionnaire.swift @@ -34,12 +34,6 @@ extension PrismaStandard { return } - if let mockWebService { - let jsonRepresentation = (try? String(data: JSONEncoder().encode(response), encoding: .utf8)) ?? "" - try? await mockWebService.upload(path: path, body: jsonRepresentation) - return - } - do { try await Firestore.firestore().document(path).setData(from: response) } catch { diff --git a/Prisma/Standard/PrismaStandard+TimeIndex.swift b/Prisma/Standard/PrismaStandard+TimeIndex.swift deleted file mode 100644 index f769101..0000000 --- a/Prisma/Standard/PrismaStandard+TimeIndex.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project -// -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT -// -// Created by Matthew Jörke on 2/28/24. -// - -import Foundation - -func constructTimeIndex(startDate: Date, endDate: Date) -> [String: Any?] { - let calendar = Calendar.current - // extract the calendar components from the startDate and the endDate - let startComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .timeZone], from: startDate) - let endComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .timeZone], from: endDate) - let isRange = startDate != endDate - - // initialize a dictionary for timeIndex and populate with info extracted above - var timeIndex: [String: Any?] = [ - "range": isRange, - "timezone": startComponents.timeZone?.identifier, - "datetime.start": startDate.toISOFormat(), - "datetime.end": endDate.toISOFormat() - ] - - // passing the timeIndex dictionary by reference so the changes persist - addTimeIndexComponents(&timeIndex, dateComponents: startComponents, suffix: ".start") - addTimeIndexComponents(&timeIndex, dateComponents: endComponents, suffix: ".end") - addTimeIndexRangeComponents(&timeIndex, startComponents: startComponents, endComponents: endComponents) - - return timeIndex -} - -// populate timeIndex dict with individual components from DateComponents (startComponents for this case) -// "inout" parameter means the argument is passed by reference (dict is modified inside the funct and changes persist) -func addTimeIndexComponents(_ timeIndex: inout [String: Any?], dateComponents: DateComponents, suffix: String) { - timeIndex["year" + suffix] = dateComponents.year - timeIndex["month" + suffix] = dateComponents.month - timeIndex["day" + suffix] = dateComponents.day - timeIndex["hour" + suffix] = dateComponents.hour - timeIndex["minute" + suffix] = dateComponents.minute - timeIndex["second" + suffix] = dateComponents.second - timeIndex["dayMinute" + suffix] = calculateDayMinute(hour: dateComponents.hour, minute: dateComponents.minute) - timeIndex["fifteenMinBucket" + suffix] = calculate15MinBucket(hour: dateComponents.hour, minute: dateComponents.minute) -} - -// if the start/end time shows that we have a time RANGE and not a time STAMP -// then add the range-related components to the timeIndex -func addTimeIndexRangeComponents(_ timeIndex: inout [String: Any?], startComponents: DateComponents, endComponents: DateComponents) { - timeIndex["year.range"] = getRange( - start: startComponents.year, - end: endComponents.year, - maxValue: Int.max - ) - timeIndex["month.range"] = getRange( - start: startComponents.month, - end: endComponents.month, - maxValue: 12, - startValue: 1 // months are 1-indexed - ) - timeIndex["day.range"] = getRange( - start: startComponents.day, - end: endComponents.day, - maxValue: daysInMonth(month: startComponents.month, year: startComponents.year), - startValue: 1 // days are 1-indexed - ) - timeIndex["hour.range"] = getRange( - start: startComponents.hour, - end: endComponents.hour, - maxValue: 23 - ) - timeIndex["dayMinute.range"] = getRange( - start: calculateDayMinute(hour: startComponents.hour, minute: startComponents.minute), - end: calculateDayMinute(hour: endComponents.hour, minute: endComponents.minute), - maxValue: 1439 - ) - timeIndex["fifteenMinBucket.range"] = getRange( - start: calculate15MinBucket(hour: startComponents.hour, minute: startComponents.minute), - end: calculate15MinBucket(hour: endComponents.hour, minute: endComponents.minute), - maxValue: 95 - ) - - // Minute and second ranges are not likely to be accurate since they often will fill the whole range. - // We will also never query on individual minutes or seconds worth of data. -} - -// swiftlint:disable discouraged_optional_collection -// passed the start and end bounds, returns the range in whichever unit passed in -func getRange(start: Int?, end: Int?, maxValue: Int?, startValue: Int = 0) -> [Int]? { - guard let startInt = start, let endInt = end, let maxValueInt = maxValue else { - return nil - } - - if startInt <= endInt { - return Array(startInt...endInt) - } else { - return Array(startInt...maxValueInt) + Array(startValue...endInt) - } -} - -func daysInMonth(month: Int?, year: Int?) -> Int? { - let dateComponents = DateComponents(year: year, month: month) - let calendar = Calendar.current - guard let date = calendar.date(from: dateComponents), - let range = calendar.range(of: .day, in: .month, for: date) else { - return nil // Provide a default value in case of nil - } - return range.count -} - -func calculateDayMinute(hour: Int?, minute: Int?) -> Int? { - guard let hour = hour, let minute = minute else { - return nil - } - return hour * 60 + minute -} - -func calculate15MinBucket(hour: Int?, minute: Int?) -> Int? { - guard let hour = hour, let minute = minute else { - return nil - } - return hour * 4 + minute / 15 -} diff --git a/Prisma/Standard/PrismaStandard.swift b/Prisma/Standard/PrismaStandard.swift index 3939b1c..22cba92 100644 --- a/Prisma/Standard/PrismaStandard.swift +++ b/Prisma/Standard/PrismaStandard.swift @@ -6,252 +6,35 @@ // SPDX-License-Identifier: MIT // -import FirebaseAuth import FirebaseFirestore -import FirebaseMessaging -import FirebaseStorage -import HealthKitOnFHIR import OSLog -import PDFKit import Spezi import SpeziAccount import SpeziFirebaseAccountStorage -import SpeziFirestore -import SpeziHealthKit -import SpeziMockWebService -import SpeziOnboarding -import SpeziQuestionnaire -import SwiftUI -actor PrismaStandard: Standard, EnvironmentAccessible, HealthKitConstraint, OnboardingConstraint, AccountStorageConstraint { + +actor PrismaStandard: Standard, EnvironmentAccessible { enum PrismaStandardError: Error { case userNotAuthenticatedYet } /// modify this study id to change the Firebase bucket. static let STUDYID = "testing" - - private static var userCollection: CollectionReference { + + static var userCollection: CollectionReference { Firestore.firestore().collection("studies").document(STUDYID).collection("users") } - - @Dependency var mockWebService: MockWebService? + @Dependency var accountStorage: FirestoreAccountStorage? @Dependency var privacyModule: PrivacyModule? - @AccountReference var account: Account - + let logger = Logger(subsystem: "Prisma", category: "Standard") - private var userDocumentReference: DocumentReference { - get async throws { - guard let details = await account.details else { - throw PrismaStandardError.userNotAuthenticatedYet - } - - return Self.userCollection.document(details.accountId) - } - } - private var userBucketReference: StorageReference { - get async throws { - guard let details = await account.details else { - throw PrismaStandardError.userNotAuthenticatedYet - } - - return Storage.storage().reference().child("users/\(details.accountId)") - } - } - - init() { if !FeatureFlags.disableFirebase { _accountStorage = Dependency(wrappedValue: FirestoreAccountStorage(storeIn: PrismaStandard.userCollection)) } } - - /// The firestore path for a given `Module`. - /// - Parameter module: The `Module` that is requested. - func getPath(module: PrismaModule) async throws -> String { - let accountId: String - if mockWebService != nil { - accountId = "USER_ID" - } else { - guard let details = await account.details else { - throw PrismaStandardError.userNotAuthenticatedYet - } - accountId = details.accountId - } - - /// the "MODULE/SUBTYPE" string. - var moduleText: String - - switch module { - case .questionnaire(let type): - // Questionnaire responses - moduleText = "\(module.description)/\(type)" - case .health(let type): - // HealthKit observations - moduleText = "\(module.description)/\(type.healthKitDescription)" - case .notifications(let type): - // notifications for user, type either "logs" or "schedule" - moduleText = "\(module.description)/data/\(type)" - } - // studies/STUDY_ID/users/USER_ID/MODULE_NAME/SUB_TYPE/... - return "studies/\(PrismaStandard.STUDYID)/users/\(accountId)/\(moduleText)/" - } - - func deletedAccount() async throws { - // delete all user associated data - do { - try await userDocumentReference.delete() - } catch { - logger.error("Could not delete user document: \(error)") - } - } - - - /// Stores the user device APNs Token in the user's document directory. - /// - /// - Parameter token: The specific device token to be stored as a `String`. - func storeToken(token: String?) async { - struct FirebaseDocumentTokenData: Codable { - let apnsToken: String? - } - - do { - let userDocument = try await userDocumentReference.getDocument() - if userDocument.exists { - let existingTokenData = try await userDocumentReference.getDocument(as: FirebaseDocumentTokenData.self) - - // Unwrap existingTokenData.apns_token and provide a default value if it's nil - if existingTokenData.apnsToken != nil { - if existingTokenData.apnsToken != token { - try await userDocumentReference.updateData(["apnsToken": token ?? ""]) - } - } - // user currently doesn't have apns token, must initialize a new field - else { - try await userDocumentReference.setData(["apnsToken": token ?? ""], merge: true) - } - } - } catch { - print("Error retrieving user document: \(error)") - } - } - - /// Stores the given consent form in the user's document directory with a unique timestamped filename. - /// - /// - Parameter consent: The consent form's data to be stored as a `PDFDocument`. - func store(consent: PDFDocument) async { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd_HHmmss" - let dateString = formatter.string(from: Date()) - - guard !FeatureFlags.disableFirebase else { - guard let basePath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { - logger.error("Could not create path for writing consent form to user document directory.") - return - } - - let filePath = basePath.appending(path: "consentForm_\(dateString).pdf") - consent.write(to: filePath) - - return - } - - do { - guard let consentData = consent.dataRepresentation() else { - logger.error("Could not store consent form.") - return - } - - let metadata = StorageMetadata() - metadata.contentType = "application/pdf" - _ = try await userBucketReference.child("consent/\(dateString).pdf").putDataAsync(consentData, metadata: metadata) - } catch { - logger.error("Could not store consent form: \(error)") - } - } - - - func create(_ identifier: AdditionalRecordId, _ details: SignupDetails) async throws { - guard let accountStorage else { - preconditionFailure("Account Storage was requested although not enabled in current configuration.") - } - try await accountStorage.create(identifier, details) - } - - func setAccountTimestamp() async { - // Add a "created_at" timestamp to the newly created user document - let timestamp = Timestamp(date: Date()) - do { - try await self.userDocumentReference.setData([ - "created_at": timestamp - ], merge: true) - print("Added timestamp to user document") - } catch { - print("Error updating document: \(error)") - } - } - - func load(_ identifier: AdditionalRecordId, _ keys: [any AccountKey.Type]) async throws -> PartialAccountDetails { - guard let accountStorage else { - preconditionFailure("Account Storage was requested although not enabled in current configuration.") - } - return try await accountStorage.load(identifier, keys) - } - - func modify(_ identifier: AdditionalRecordId, _ modifications: AccountModifications) async throws { - guard let accountStorage else { - preconditionFailure("Account Storage was requested although not enabled in current configuration.") - } - try await accountStorage.modify(identifier, modifications) - } - - func clear(_ identifier: AdditionalRecordId) async { - guard let accountStorage else { - preconditionFailure("Account Storage was requested although not enabled in current configuration.") - } - await accountStorage.clear(identifier) - } - - func delete(_ identifier: AdditionalRecordId) async throws { - guard let accountStorage else { - preconditionFailure("Account Storage was requested although not enabled in current configuration.") - } - try await accountStorage.delete(identifier) - } - - /// Authorizes access to the Prisma keychain access group for the currently signed-in user. - /// - /// If the current user is signed in, this function authorizes their access to the Prisma notifications keychain access group identifier. - /// If the user is not signed in, or if an error occurs during the authorization process, appropriate error handling is performed, and the user may be logged out. - /// - /// - Parameters: - /// - user: The current user object. - /// - accessGroup: The identifier of the access group to authorize. - /// - /// - Throws: An error if an issue occurs during the authorization process. - func authorizeAccessGroupForCurrentUser() async { - guard let user = Auth.auth().currentUser else { - print("No signed in user.") - return - } - let accessGroup = "637867499T.edu.stanford.cs342.2024.behavior" - - guard (try? Auth.auth().getStoredUser(forAccessGroup: accessGroup)) == nil else { - print("Access group already shared ...") - return - } - - do { - try Auth.auth().useUserAccessGroup(accessGroup) - try await Auth.auth().updateCurrentUser(user) - } catch let error as NSError { - print("Error changing user access group: %@", error) - // log out the user if fails - try? Auth.auth().signOut() - } - } } diff --git a/PrismaPushNotificationsExtension/NotificationService.swift b/PrismaPushNotificationsExtension/NotificationService.swift index 032a14c..a691ef2 100644 --- a/PrismaPushNotificationsExtension/NotificationService.swift +++ b/PrismaPushNotificationsExtension/NotificationService.swift @@ -5,17 +5,13 @@ // // SPDX-License-Identifier: MIT // -// This file implements an extension to the Notification Service class, which is used to upload timestamps to -// Firestore on receival of background notifications. -// -// Created by Bryant Jimenez on 2/1/24. -// import Firebase import FirebaseFirestore import UserNotifications +/// This file implements an extension to the Notification Service class, which is used to upload timestamps to Firestore on receival of background notifications. class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? @@ -24,11 +20,10 @@ class NotificationService: UNNotificationServiceExtension { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { FirebaseApp.configure() - let accessGroup = "637867499T.edu.stanford.cs342.2024.behavior" do { - try Auth.auth().useUserAccessGroup(accessGroup) + try Auth.auth().useUserAccessGroup(Constants.keyChainGroup) } catch let error as NSError { - print("Error changing user access group: %@", error) + print("Error changing user access group: %@", error) } self.contentHandler = contentHandler @@ -50,19 +45,3 @@ class NotificationService: UNNotificationServiceExtension { } } } - - -extension Date { - /// converts Date object to ISO Format string. Can optionally pass in a time zone to convert it to. - /// If no timezone is passed, it converts the Date object using the local time zone. - func toISOFormat(timezone: TimeZone? = nil) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withFullDate, .withTime, .withColonSeparatorInTime, .withFractionalSeconds] - if let timezone = timezone { - formatter.timeZone = timezone - } else { - formatter.timeZone = TimeZone.current - } - return formatter.string(from: self) - } -} diff --git a/README.md b/README.md index f5a91ec..4ef427a 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,12 @@ The CS342 2024 PRISMA application is using the [Spezi](https://github.com/Stanfo > [!NOTE] > Do you want to try out the CS342 2024 PRISMA application? You can download it to your iOS device using [TestFlight](https://testflight.apple.com/join/bPu7kUoM)! -> -The CS342 2024 Prisma app as of March 14, 2023 includes added functionality for push notifications, controlling personal data usage via privacy controls, and authenticated chat interface dialogue. -For the Chat Interface, to access this feature, please run the frontend + backend in the StanfordHCI/Prisma repository, so that chat is rendered on localhost, which this iOS repository depends on. Once the actual website is set up, replace the URL in ChatView with the permanent URL. +For the Chat Interface, to access this feature, please run the frontend + backend in the StanfordHCI/Prisma repository, so that chat is rendered on localhost, which this iOS repository depends on. + ## CS342 2024 PRISMA Features + The following are screenshots showing various aspects of the Prisma application. | Chat Interface | Notification Permissions | Data View | @@ -34,16 +34,12 @@ The following are screenshots showing various aspects of the Prisma application. ## Contributing -| Name | Contribution | -|------------|--------------| -| **Caroline** | Implemented the UI, publisher, fetching, and modifying features for Firestore data given the user’s selection on data upload and redaction of data for the privacy controls. | -| **Dhruv** | Wrote centralized privacy module class for management and storage of selected data. Worked collaboratively with Evelyn S. to create an end to end pipeline of chat interface authentication. | -| **Evelyn H.** | Implemented the UI for privacy controls, fetching and updating data in Firestore to reflect user changes in hiding data by timestamp or time range. | -| **Evelyn S.** | Worked collaboratively with Dhruv to create an end to end pipeline of chat interface authentication. The iOS app sends a JWT to the frontend, which then verifies the JWT using Firebase Admin SDK in the backend, and the user can then access the chat view -| **Bryant** | Implemented client side handling for push notification registration + handling, as well as the backend listener system and scheduling for notifications/schedule changes. Also added testing framework to backend. | - +Contributions to this project are welcome. Please make sure to read the [contribution guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md) and the [contributor covenant code of conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) first. ## License -This project is licensed under the MIT License. See [Licenses](LICENSES) for more information. +This project is licensed under the MIT License. See [Licenses](https://github.com/StanfordSpezi/Spezi/tree/main/LICENSES) for more information. + +![Spezi Footer](https://raw.githubusercontent.com/StanfordSpezi/.github/main/assets/Footer.png#gh-light-mode-only) +![Spezi Footer](https://raw.githubusercontent.com/StanfordSpezi/.github/main/assets/Footer~dark.png#gh-dark-mode-only)