diff --git a/Intake.xcodeproj/project.pbxproj b/Intake.xcodeproj/project.pbxproj index 9dc56b1..a615ee0 100644 --- a/Intake.xcodeproj/project.pbxproj +++ b/Intake.xcodeproj/project.pbxproj @@ -57,6 +57,7 @@ 2FF53D8B2A8725DE00042B76 /* SpeziMockWebService in Frameworks */ = {isa = PBXBuildFile; productRef = 2FF53D8A2A8725DE00042B76 /* SpeziMockWebService */; }; 2FF53D8D2A8729D600042B76 /* IntakeStandard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF53D8C2A8729D600042B76 /* IntakeStandard.swift */; }; 3C89F66D2B9D948B00A4F52D /* PatientInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C89F66C2B9D948B00A4F52D /* PatientInfo.swift */; }; + 510CAAF12BA0DFFB00872B1A /* MedicationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510CAAF02BA0DFFB00872B1A /* MedicationTests.swift */; }; 511827962B740192002033A0 /* SurgeryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 511827952B740191002033A0 /* SurgeryView.swift */; }; 51805C122B81853800D17109 /* IntakeMedication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51805C112B81853700D17109 /* IntakeMedication.swift */; }; 51805C152B81857100D17109 /* IntakeMedicationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51805C142B81857100D17109 /* IntakeMedicationViewModel.swift */; }; @@ -180,6 +181,7 @@ 2FE5DCAC29EE6107004B9AB4 /* AccountOnboarding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountOnboarding.swift; sourceTree = ""; }; 2FF53D8C2A8729D600042B76 /* IntakeStandard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IntakeStandard.swift; sourceTree = ""; }; 3C89F66C2B9D948B00A4F52D /* PatientInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatientInfo.swift; sourceTree = ""; }; + 510CAAF02BA0DFFB00872B1A /* MedicationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MedicationTests.swift; sourceTree = ""; }; 511827952B740191002033A0 /* SurgeryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SurgeryView.swift; sourceTree = ""; }; 51805C112B81853700D17109 /* IntakeMedication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntakeMedication.swift; sourceTree = ""; }; 51805C142B81857100D17109 /* IntakeMedicationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntakeMedicationViewModel.swift; sourceTree = ""; }; @@ -560,6 +562,7 @@ isa = PBXGroup; children = ( 2F4E237D2989A2FE0013F3D9 /* LaunchTests.swift */, + 510CAAF02BA0DFFB00872B1A /* MedicationTests.swift */, ); path = IntakeUITests; sourceTree = ""; @@ -928,6 +931,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 510CAAF12BA0DFFB00872B1A /* MedicationTests.swift in Sources */, 2F4E237E2989A2FE0013F3D9 /* LaunchTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Intake.xcodeproj/xcshareddata/xcschemes/Intake.xcscheme b/Intake.xcodeproj/xcshareddata/xcschemes/Intake.xcscheme index 1d8313b..c545dfe 100644 --- a/Intake.xcodeproj/xcshareddata/xcschemes/Intake.xcscheme +++ b/Intake.xcodeproj/xcshareddata/xcschemes/Intake.xcscheme @@ -81,6 +81,14 @@ argument = "--disableFirebase" isEnabled = "YES"> + + + + @@ -93,6 +101,10 @@ argument = "--testSchedule" isEnabled = "NO"> + + diff --git a/Intake/Allergy Records/AllergyLLMAssistant.swift b/Intake/Allergy Records/AllergyLLMAssistant.swift index bec4bc7..3b4d94f 100644 --- a/Intake/Allergy Records/AllergyLLMAssistant.swift +++ b/Intake/Allergy Records/AllergyLLMAssistant.swift @@ -68,6 +68,7 @@ struct UpdateAllergyFunction: LLMFunction { } } +// The AllergyLLMAssistant allows the user to ask the chat questions about their current allergies and add new allergies to their data. struct AllergyLLMAssistant: View { @Environment(DataStore.self) private var data @Environment(NavigationPathWrapper.self) private var navigationPath diff --git a/Intake/Home.swift b/Intake/Home.swift index 7ad97eb..48d7326 100644 --- a/Intake/Home.swift +++ b/Intake/Home.swift @@ -32,7 +32,11 @@ struct StartButton: View { var body: some View { Button(action: { - navigationPath.append(NavigationViews.general) + if FeatureFlags.testMedication { + navigationPath.append(NavigationViews.medication) + } else { + navigationPath.append(NavigationViews.general) + } }) { Text("Create New Form") .font(.headline) diff --git a/Intake/IntakeTestingSetup.swift b/Intake/IntakeTestingSetup.swift index e29ba37..abd5539 100644 --- a/Intake/IntakeTestingSetup.swift +++ b/Intake/IntakeTestingSetup.swift @@ -5,11 +5,14 @@ // // SPDX-License-Identifier: MIT // - +import ModelsR4 +import SpeziFHIR +import SpeziFHIRMockPatients import SwiftUI private struct IntakeAppTestingSetup: ViewModifier { @AppStorage(StorageKeys.onboardingFlowComplete) var completedOnboardingFlow = false + @Environment(FHIRStore.self) private var store func body(content: Content) -> some View { content @@ -20,6 +23,11 @@ private struct IntakeAppTestingSetup: ViewModifier { if FeatureFlags.showOnboarding { completedOnboardingFlow = false } + if FeatureFlags.testPatient { + let bundle = await ModelsR4.Bundle.gonzalo160Duenas839 + store.removeAllResources() + store.load(bundle: bundle) + } } } } diff --git a/Intake/Medication View/IntakeDosage.swift b/Intake/Medication View/IntakeDosage.swift index 39d8a4c..788de09 100644 --- a/Intake/Medication View/IntakeDosage.swift +++ b/Intake/Medication View/IntakeDosage.swift @@ -15,6 +15,7 @@ import Foundation import SpeziMedication +// The IntakeDosage struct has a localizedDescription that describes the dosage information. struct IntakeDosage: Dosage, Codable { var localizedDescription: String } diff --git a/Intake/Medication View/IntakeMedication.swift b/Intake/Medication View/IntakeMedication.swift index 678c7e9..aee0db7 100644 --- a/Intake/Medication View/IntakeMedication.swift +++ b/Intake/Medication View/IntakeMedication.swift @@ -15,6 +15,7 @@ import Foundation import SpeziMedication +// This describes the IntakeMedication struct which contains a localizedDescription (medication name) and a list of dosages. struct IntakeMedication: Medication, Comparable, Codable { var localizedDescription: String var dosages: [IntakeDosage] diff --git a/Intake/Medication View/IntakeMedicationInstance.swift b/Intake/Medication View/IntakeMedicationInstance.swift index 078c0bc..2d694c0 100644 --- a/Intake/Medication View/IntakeMedicationInstance.swift +++ b/Intake/Medication View/IntakeMedicationInstance.swift @@ -15,6 +15,7 @@ import Foundation import SpeziMedication +// This defines an IntakeMedicationInstance which is composed of an id, an IntakeMedication type, a dosage, and a schedule. struct IntakeMedicationInstance: MedicationInstance, MedicationInstanceInitializable, Codable { let id: UUID let type: IntakeMedication diff --git a/Intake/Medication View/IntakeMedicationViewModel.swift b/Intake/Medication View/IntakeMedicationViewModel.swift index 45342ac..c5f575d 100644 --- a/Intake/Medication View/IntakeMedicationViewModel.swift +++ b/Intake/Medication View/IntakeMedicationViewModel.swift @@ -19,6 +19,7 @@ import SpeziFHIR import SpeziMedication import SwiftUI +// The IntakeMedicationSettingsViewModel takes the patient's FHIRStore medications and adds any that match to the medicationOptions to the medicationInstances list which is then used for the MedicationContentView. @Observable class IntakeMedicationSettingsViewModel: Module, MedicationSettingsViewModel, CustomStringConvertible { var medicationInstances: Set = [] @@ -46,7 +47,9 @@ class IntakeMedicationSettingsViewModel: Module, MedicationSettingsViewModel, Cu .joined(separator: ", ") } + // The init is modified from the SpeziMedication examples to load in the existing patient medications from their FHIRStore data. init(existingMedications: [FHIRResource]) { // swiftlint:disable:this function_body_length + // medicationOptions provides the list of medications options chosen as the most common medications from the sample patients self.medicationOptions = [ IntakeMedication( localizedDescription: "Hydrochlorothiazide 25 MG Oral Tablet", @@ -99,17 +102,20 @@ class IntakeMedicationSettingsViewModel: Module, MedicationSettingsViewModel, Cu ] var foundMedications: [IntakeMedicationInstance] = [] + // This function matches any patient medication from FHIRStore to a medication in medicationOptions. if !existingMedications.isEmpty { for medication in existingMedications { for option in medicationOptions where option.localizedDescription == medication.displayName { var medSchedule: SpeziMedication.Schedule let medRequest = medicationRequest(resource: medication) if case .boolean(let asNeeded) = medRequest?.dosageInstruction?.first?.asNeeded { + // Checks if medication is asNeeded, otherwise finds the frequency in days. if let asNeededbool = asNeeded.value?.bool { if asNeededbool { medSchedule = SpeziMedication.Schedule(frequency: .asNeeded) } else { let intValue: Int + // Need to convert from FHIRDecimal to int. let interval = medRequest?.dosageInstruction?.first?.timing?.repeat?.period?.value?.decimal if let interval = interval { intValue = interval.int @@ -122,7 +128,7 @@ class IntakeMedicationSettingsViewModel: Module, MedicationSettingsViewModel, Cu guard let firstDosage = option.dosages.first else { continue } - + // Create an IntakeMedicationInstance to the data. let intakeMedicationInstance = IntakeMedicationInstance( type: option, dosage: firstDosage, @@ -139,7 +145,8 @@ class IntakeMedicationSettingsViewModel: Module, MedicationSettingsViewModel, Cu func persist(medicationInstances: Set) async throws { self.medicationInstances = medicationInstances } - + + // Converts a FHIRResource into a MedicationRequest. func medicationRequest(resource: FHIRResource) -> MedicationRequest? { guard case let .r4(resource) = resource.versionedResource, let medicationRequest = resource as? ModelsR4.MedicationRequest else { @@ -149,6 +156,7 @@ class IntakeMedicationSettingsViewModel: Module, MedicationSettingsViewModel, Cu } } +// Needed to convert the FHIRDecimal into an Int. extension Decimal { var int: Int { let intVal = NSDecimalNumber(decimal: self).intValue // swiftlint:disable:this legacy_objc_type diff --git a/Intake/Medication View/MedicationContentView.swift b/Intake/Medication View/MedicationContentView.swift index f28e8c5..efce40a 100644 --- a/Intake/Medication View/MedicationContentView.swift +++ b/Intake/Medication View/MedicationContentView.swift @@ -17,38 +17,49 @@ import SpeziFHIR import SpeziMedication import SwiftUI +// This view displays the medications in the patient's FHIR data, and allows them to add, update and delete their medications. struct MedicationContentView: View { @Environment(FHIRStore.self) private var fhirStore @Environment(NavigationPathWrapper.self) private var navigationPath @Environment(DataStore.self) private var data @State private var presentSettings = false - + @State private var medicationSettingsViewModel: IntakeMedicationSettingsViewModel? - + var body: some View { VStack { if let medicationSettingsViewModel { MedicationSettings(allowEmptySave: true, medicationSettingsViewModel: medicationSettingsViewModel) { - data.medicationData = medicationSettingsViewModel.medicationInstances - navigationPath.path.append(NavigationViews.allergies) + if FeatureFlags.skipToScrollable { + data.medicationData = medicationSettingsViewModel.medicationInstances + navigationPath.path.append(NavigationViews.pdfs) + } else { + data.medicationData = medicationSettingsViewModel.medicationInstances + navigationPath.path.append(NavigationViews.allergies) + } } - .navigationTitle("Medications") - .navigationBarItems(trailing: NavigationLink(destination: MedicationLLMAssistant(presentingAccount: .constant(false))) { - Text("Chat") - }) + .navigationTitle("Medications") + .navigationBarItems(trailing: NavigationLink(destination: MedicationLLMAssistant(presentingAccount: .constant(false))) { + Text("Chat") + }) } else { ProgressView() } } - .task { - let patientMedications = fhirStore.llmMedications - self.medicationSettingsViewModel = IntakeMedicationSettingsViewModel(existingMedications: patientMedications) - var initialData: Set = [] - if let newMed = self.medicationSettingsViewModel?.medicationInstances { - initialData = newMed - } - data.medicationData = initialData + // Updates the medicationSettingsViewModel init if there's a change to the patient's fhirStore medications. + .onChange(of: fhirStore.llmMedications) { + medicationSettingsViewModel = .init(existingMedications: fhirStore.llmMedications) + } + // Task to initialize the MedicationSettingsViewModel with the patient's existing fhirStore medications. + .task { + let patientMedications = fhirStore.llmMedications + self.medicationSettingsViewModel = IntakeMedicationSettingsViewModel(existingMedications: patientMedications) + var initialData: Set = [] + if let newMed = self.medicationSettingsViewModel?.medicationInstances { + initialData = newMed } + data.medicationData = initialData + } } init() {} diff --git a/Intake/Medication View/MedicationLLMAssistant.swift b/Intake/Medication View/MedicationLLMAssistant.swift index 9f65ae4..ad6fd22 100644 --- a/Intake/Medication View/MedicationLLMAssistant.swift +++ b/Intake/Medication View/MedicationLLMAssistant.swift @@ -18,6 +18,7 @@ import SpeziLLMLocal import SpeziLLMOpenAI import SwiftUI +// Adds the current patient medications to the system prompt. func getCurrentPatientMedications(medicationList: Set) -> String? { var medicationDetails = "The patient is currently taking several medications:" print(medicationList) @@ -31,6 +32,7 @@ func getCurrentPatientMedications(medicationList: Set) return medicationDetails.isEmpty ? nil : medicationDetails } +// Provides medication LLM assistant functionality to allow the patient to ask about their current medications. struct MedicationLLMAssistant: View { @Environment(DataStore.self) private var data @Environment(NavigationPathWrapper.self) private var navigationPath diff --git a/Intake/SharedContext/FeatureFlags.swift b/Intake/SharedContext/FeatureFlags.swift index dc92455..0fa3839 100644 --- a/Intake/SharedContext/FeatureFlags.swift +++ b/Intake/SharedContext/FeatureFlags.swift @@ -23,4 +23,7 @@ enum FeatureFlags { #endif /// Adds a test task to the schedule at the current time static let testSchedule = CommandLine.arguments.contains("--testSchedule") + static let testPatient = CommandLine.arguments.contains("--testPatient") + static let testMedication = CommandLine.arguments.contains("--testMedication") + static let skipToScrollable = CommandLine.arguments.contains("--skipToScrollable") } diff --git a/IntakeUITests/MedicationTests.swift b/IntakeUITests/MedicationTests.swift new file mode 100644 index 0000000..4b50491 --- /dev/null +++ b/IntakeUITests/MedicationTests.swift @@ -0,0 +1,54 @@ +// +// MedicationTests.swift +// IntakeUITests +// +// Created by Kate Callon on 3/12/24. +// +// +// This source file is part of the Intake based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import XCTest + +class MedicationTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + + continueAfterFailure = false + + let app = XCUIApplication() + app.launchArguments = ["--skipOnboarding", "--disableFirebase", "--testPatient", "--testMedication", "--skipToScrollable"] + app.launch() + } + + func testMedications() throws { + let app = XCUIApplication() + + // Small workaround to wait until the madications loaded into main memory + sleep(8) + + XCTAssertEqual(app.state, .runningForeground) + app.buttons["Create New Form"].tap() + + XCTAssertTrue(app.staticTexts["Hydrochlorothiazide 25 MG Oral Tablet"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["amLODIPine 2.5 MG Oral Tablet"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.navigationBars["Medication Settings"].buttons["Add New Medication"].waitForExistence(timeout: 2)) + XCTAssertTrue(app.navigationBars["Medication Settings"].buttons["Chat"].waitForExistence(timeout: 2)) + app.navigationBars["Medication Settings"].buttons["Add New Medication"].tap() + app.buttons["Verapamil Hydrochloride 40 MG"].tap() + app.buttons["Save Dosage"].tap() + app.buttons["Add Medication"].tap() + XCTAssertTrue(app.staticTexts["Verapamil Hydrochloride 40 MG"].waitForExistence(timeout: 5)) + app.buttons["Save Medications"].tap() + XCTAssertTrue(app.navigationBars["Patient Form"].waitForExistence(timeout: 2)) +// XCTAssertTrue(app.staticTexts["Hydrochlorothiazide 25 MG Oral Tablet"].waitForExistence(timeout: 5)) +// XCTAssertTrue(app.staticTexts["amLODIPine 2.5 MG Oral Tablet"].waitForExistence(timeout: 5)) +// XCTAssertTrue(app.staticTexts["Verapamil Hydrochloride 40 MG"].waitForExistence(timeout: 5)) +// XCTAssertTrue(app.staticTexts["2.5 MG - Every Day"].waitForExistence(timeout: 5)) + } +}