From 6ad0a6751fea3e53d9e2be327ca205cca1112af8 Mon Sep 17 00:00:00 2001 From: Rahul Malhotra <16497903+rahulmalhotra@users.noreply.github.com> Date: Tue, 10 Sep 2024 18:49:42 +0530 Subject: [PATCH 01/17] Update Android Studio version in Prerequisites document (#2670) Update android studio version to Koala and above which is compatible with the Android Gradle Plugin (AGP 8.5.0) used in the project. Ref: https://developer.android.com/studio/releases#android_gradle_plugin_and_android_studio_compatibility --- docs/contrib/prereqs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contrib/prereqs.md b/docs/contrib/prereqs.md index 302db25745..885d3a8029 100644 --- a/docs/contrib/prereqs.md +++ b/docs/contrib/prereqs.md @@ -3,7 +3,7 @@ The following software is recommended for contributing to this project: * Java 17 -* Android Studio 4.2+ +* Android Studio Koala | 2024.1.1+ * Node.js * Install e.g. [via package manager](https://nodejs.org/en/download/package-manager/) * Needed for the `prettier` plugin we use to format `XML` files From d11424560b5e1446e7782fa761d32e27ba966788 Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Wed, 18 Sep 2024 16:00:41 +0100 Subject: [PATCH 02/17] Bump the SDC library release version to 1.2.0 (#2674) --- buildSrc/src/main/kotlin/Releases.kt | 2 +- docs/use/api.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/Releases.kt b/buildSrc/src/main/kotlin/Releases.kt index 7b1e92f953..30172a9762 100644 --- a/buildSrc/src/main/kotlin/Releases.kt +++ b/buildSrc/src/main/kotlin/Releases.kt @@ -54,7 +54,7 @@ object Releases { object DataCapture : LibraryArtifact { override val artifactId = "data-capture" - override val version = "1.1.0" + override val version = "1.2.0" override val name = "Android FHIR Structured Data Capture Library" } diff --git a/docs/use/api.md b/docs/use/api.md index bf7e90c8f4..c9c1365ec0 100644 --- a/docs/use/api.md +++ b/docs/use/api.md @@ -1,6 +1,6 @@ # API * [Engine](api/engine/1.0.0/index.html) -* [Data Capture](api/data-capture/1.1.0/index.html) +* [Data Capture](api/data-capture/1.2.0/index.html) * [Workflow](api/workflow/0.1.0-alpha04/index.html) * [Knowledge](api/knowledge/0.1.0-beta01/index.html) From 7fd7d56ee7aba96af3b50fc18211a01f560b5030 Mon Sep 17 00:00:00 2001 From: aditya-07 Date: Fri, 27 Sep 2024 15:42:30 +0530 Subject: [PATCH 03/17] Updated the library versions and code in code labs (#2676) --- codelabs/datacapture/README.md | 20 +++++++----- codelabs/datacapture/app/build.gradle.kts | 12 +++---- .../google/codelab/sdclibrary/MainActivity.kt | 24 +++----------- codelabs/engine/README.md | 32 ++++++++++--------- codelabs/engine/app/build.gradle.kts | 18 +++++------ .../codelabs/engine/PatientListFragment.kt | 6 ++-- .../codelabs/engine/PatientListViewModel.kt | 8 ++--- 7 files changed, 56 insertions(+), 64 deletions(-) diff --git a/codelabs/datacapture/README.md b/codelabs/datacapture/README.md index 41a8504378..e9a7f9e196 100644 --- a/codelabs/datacapture/README.md +++ b/codelabs/datacapture/README.md @@ -76,8 +76,8 @@ of the `app/build.gradle.kts` file of your project: dependencies { // ... - implementation("com.google.android.fhir:data-capture:0.1.0-beta03") - implementation("androidx.fragment:fragment-ktx:1.4.1") + implementation("com.google.android.fhir:data-capture:1.0.0") + implementation("androidx.fragment:fragment-ktx:1.5.5") } ``` @@ -173,6 +173,13 @@ if (savedInstanceState == null) { add(R.id.fragment_container_view, args = questionnaireParams) } } +// Submit button callback +supportFragmentManager.setFragmentResultListener( + QuestionnaireFragment.SUBMIT_REQUEST_KEY, + this, +) { _, _ -> + submitQuestionnaire() +} ``` Learn more about @@ -244,12 +251,9 @@ questionnaire is already set up for Find the `submitQuestionnaire()` method and add the following code: ```kotlin -lifecycleScope.launch { - val questionnaire = - jsonParser.parseResource(questionnaireJsonString) as Questionnaire - val bundle = ResourceMapper.extract(questionnaire, questionnaireResponse) - Log.d("extraction result", jsonParser.encodeResourceToString(bundle)) -} +val questionnaire = jsonParser.parseResource(questionnaireJsonString) as Questionnaire +val bundle = ResourceMapper.extract(questionnaire, questionnaireResponse) +Log.d("extraction result", jsonParser.encodeResourceToString(bundle)) ``` `ResourceMapper.extract()` requires a HAPI FHIR Questionnaire, which you can diff --git a/codelabs/datacapture/app/build.gradle.kts b/codelabs/datacapture/app/build.gradle.kts index f252f95236..4c6cc3949c 100644 --- a/codelabs/datacapture/app/build.gradle.kts +++ b/codelabs/datacapture/app/build.gradle.kts @@ -38,15 +38,15 @@ android { } dependencies { - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") - implementation("androidx.core:core-ktx:1.12.0") - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("com.google.android.material:material:1.10.0") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.2") + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("com.google.android.material:material:1.12.0") implementation("androidx.constraintlayout:constraintlayout:2.1.4") testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") // 3 Add dependencies for Structured Data Capture Library and Fragment KTX } diff --git a/codelabs/datacapture/app/src/main/java/com/google/codelab/sdclibrary/MainActivity.kt b/codelabs/datacapture/app/src/main/java/com/google/codelab/sdclibrary/MainActivity.kt index 74ecbe157b..a7f922c760 100644 --- a/codelabs/datacapture/app/src/main/java/com/google/codelab/sdclibrary/MainActivity.kt +++ b/codelabs/datacapture/app/src/main/java/com/google/codelab/sdclibrary/MainActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,8 +17,6 @@ package com.google.codelab.sdclibrary import android.os.Bundle -import android.view.Menu -import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity class MainActivity : AppCompatActivity() { @@ -32,24 +30,12 @@ class MainActivity : AppCompatActivity() { // 4.2 Replace with code from the codelab to add a questionnaire fragment. } - private fun submitQuestionnaire() { - // 5 Replace with code from the codelab to get a questionnaire response. + private fun submitQuestionnaire() = + lifecycleScope.launch { + // 5 Replace with code from the codelab to get a questionnaire response. - // 6 Replace with code from the codelab to extract FHIR resources from QuestionnaireResponse. - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.submit_menu, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.submit) { - submitQuestionnaire() - return true + // 6 Replace with code from the codelab to extract FHIR resources from QuestionnaireResponse. } - return super.onOptionsItemSelected(item) - } private fun getStringFromAssets(fileName: String): String { return assets.open(fileName).bufferedReader().use { it.readText() } diff --git a/codelabs/engine/README.md b/codelabs/engine/README.md index 9b116e4cbb..67bfd6fabc 100644 --- a/codelabs/engine/README.md +++ b/codelabs/engine/README.md @@ -125,7 +125,7 @@ file of your project: dependencies { // ... - implementation("com.google.android.fhir:engine:0.1.0-beta05") + implementation("com.google.android.fhir:engine:1.0.0") } ``` @@ -256,6 +256,8 @@ outlined below will guide you through the process. override fun getConflictResolver() = AcceptLocalConflictResolver override fun getFhirEngine() = FhirApplication.fhirEngine(applicationContext) + + override fun getUploadStrategy() = UploadStrategy.AllChangesSquashedBundlePut } ``` @@ -282,7 +284,7 @@ outlined below will guide you through the process. ```kotlin when (syncJobStatus) { - is SyncJobStatus.Finished -> { + is CurrentSyncJobStatus.Succeeded -> { Toast.makeText(requireContext(), "Sync Finished", Toast.LENGTH_SHORT).show() viewModel.searchPatientsByName("") } @@ -434,20 +436,20 @@ the UI to update, incorporate the following conditional code block: ```kotlin viewModelScope.launch { - val fhirEngine = FhirApplication.fhirEngine(getApplication()) - if (nameQuery.isNotEmpty()) { - val searchResult = fhirEngine.search { - filter( - Patient.NAME, - { - modifier = StringFilterModifier.CONTAINS - value = nameQuery - }, - ) + val fhirEngine = FhirApplication.fhirEngine(getApplication()) + val searchResult = fhirEngine.search { + if (nameQuery.isNotEmpty()) { + filter( + Patient.NAME, + { + modifier = StringFilterModifier.CONTAINS + value = nameQuery + }, + ) + } + } + liveSearchedPatients.value = searchResult.map { it.resource } } - liveSearchedPatients.value = searchResult.map { it.resource } - } -} ``` Here, if the `nameQuery` is not empty, the search function will filter the diff --git a/codelabs/engine/app/build.gradle.kts b/codelabs/engine/app/build.gradle.kts index 75c7ab8f62..23c48e875f 100644 --- a/codelabs/engine/app/build.gradle.kts +++ b/codelabs/engine/app/build.gradle.kts @@ -37,18 +37,18 @@ android { } dependencies { - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.2") - implementation("androidx.core:core-ktx:1.12.0") - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("com.google.android.material:material:1.10.0") + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("com.google.android.material:material:1.12.0") implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.work:work-runtime-ktx:2.8.1") + implementation("androidx.work:work-runtime-ktx:2.9.1") testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") - implementation("com.google.android.fhir:engine:0.1.0-beta05") - implementation("androidx.fragment:fragment-ktx:1.6.1") + implementation("com.google.android.fhir:engine:1.0.0") + implementation("androidx.fragment:fragment-ktx:1.8.3") } diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListFragment.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListFragment.kt index 8c260fabf0..b00b3bdf89 100644 --- a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListFragment.kt +++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListFragment.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,7 +35,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.android.fhir.codelabs.engine.databinding.FragmentPatientListViewBinding -import com.google.android.fhir.sync.SyncJobStatus +import com.google.android.fhir.sync.CurrentSyncJobStatus import kotlinx.coroutines.launch class PatientListFragment : Fragment() { @@ -75,7 +75,7 @@ class PatientListFragment : Fragment() { } } - private fun handleSyncJobStatus(syncJobStatus: SyncJobStatus) { + private fun handleSyncJobStatus(syncJobStatus: CurrentSyncJobStatus) { // Add code to display Toast when sync job is complete } diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt index b25123a148..3c9a099aa8 100644 --- a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt +++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,16 +23,16 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.fhir.search.Order import com.google.android.fhir.search.search -import com.google.android.fhir.sync.SyncJobStatus +import com.google.android.fhir.sync.CurrentSyncJobStatus import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Patient class PatientListViewModel(application: Application) : AndroidViewModel(application) { - private val _pollState = MutableSharedFlow() + private val _pollState = MutableSharedFlow() - val pollState: Flow + val pollState: Flow get() = _pollState val liveSearchedPatients = MutableLiveData>() From 8d3dbc8b9cc1a167f350980753de2847d9ac7974 Mon Sep 17 00:00:00 2001 From: Rahul Malhotra <16497903+rahulmalhotra@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:44:20 +0000 Subject: [PATCH 04/17] Add argument to disable Submit anyway button in the questionnaire confirmation popup (#2673) * Add argument to disable Submit anyway button in the questionnaire confirmation popup * Incorporated review feedback. * Incorporated additional review feedback. --- .../fhir/datacapture/QuestionnaireFragment.kt | 33 ++++++- ...ireValidationErrorMessageDialogFragment.kt | 24 +++-- ...alidationErrorMessageDialogFragmentTest.kt | 92 +++++++++++++++++++ 3 files changed, 138 insertions(+), 11 deletions(-) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt index b6c357d107..b84a625a10 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt @@ -112,11 +112,23 @@ class QuestionnaireFragment : Fragment() { } else { val errorViewModel: QuestionnaireValidationErrorViewModel by activityViewModels() errorViewModel.setQuestionnaireAndValidation(viewModel.questionnaire, validationMap) - QuestionnaireValidationErrorMessageDialogFragment() - .show( - requireActivity().supportFragmentManager, - QuestionnaireValidationErrorMessageDialogFragment.TAG, - ) + val validationErrorMessageDialog = QuestionnaireValidationErrorMessageDialogFragment() + if (requireArguments().containsKey(EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON)) { + validationErrorMessageDialog.arguments = + Bundle().apply { + putBoolean( + EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON, + requireArguments() + .getBoolean( + EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON, + ), + ) + } + } + validationErrorMessageDialog.show( + requireActivity().supportFragmentManager, + QuestionnaireValidationErrorMessageDialogFragment.TAG, + ) } } } @@ -407,6 +419,11 @@ class QuestionnaireFragment : Fragment() { args.add(EXTRA_SHOW_NAVIGATION_IN_DEFAULT_LONG_SCROLL to value) } + /** Setter to show/hide the Submit anyway button. This button is visible by default. */ + fun setShowSubmitAnywayButton(value: Boolean) = apply { + args.add(EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON to value) + } + @VisibleForTesting fun buildArgs() = bundleOf(*args.toTypedArray()) /** @return A [QuestionnaireFragment] with provided [Bundle] arguments. */ @@ -509,6 +526,12 @@ class QuestionnaireFragment : Fragment() { internal const val EXTRA_SHOW_NAVIGATION_IN_DEFAULT_LONG_SCROLL = "show-navigation-in-default-long-scroll" + /** + * A [Boolean] extra to show or hide the Submit anyway button in the questionnaire. Default is + * true. + */ + internal const val EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON = "show-submit-anyway-button" + fun builder() = Builder() } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt index 2db2576875..f57b6949b7 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,17 +51,23 @@ internal class QuestionnaireValidationErrorMessageDialogFragment( override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { isCancelable = false - return MaterialAlertDialogBuilder(requireContext()) - .setView(onCreateCustomView()) - .setPositiveButton(R.string.questionnaire_validation_error_fix_button_text) { dialog, _ -> + val currentDialog = + MaterialAlertDialogBuilder(requireContext()).setView(onCreateCustomView()).setPositiveButton( + R.string.questionnaire_validation_error_fix_button_text, + ) { dialog, _ -> setFragmentResult(RESULT_CALLBACK, bundleOf(RESULT_KEY to RESULT_VALUE_FIX)) dialog?.dismiss() } - .setNegativeButton(R.string.questionnaire_validation_error_submit_button_text) { dialog, _ -> + if (arguments == null || requireArguments().getBoolean(EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON, true)) { + currentDialog.setNegativeButton(R.string.questionnaire_validation_error_submit_button_text) { + dialog, + _, + -> setFragmentResult(RESULT_CALLBACK, bundleOf(RESULT_KEY to RESULT_VALUE_SUBMIT)) dialog?.dismiss() } - .create() + } + return currentDialog.create() } @VisibleForTesting @@ -97,6 +103,12 @@ internal class QuestionnaireValidationErrorMessageDialogFragment( const val RESULT_KEY = "result" const val RESULT_VALUE_FIX = "result_fix" const val RESULT_VALUE_SUBMIT = "result_submit" + + /** + * A [Boolean] extra to show or hide the Submit anyway button in the questionnaire. Default is + * true. + */ + internal const val EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON = "show-submit-anyway-button" } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragmentTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragmentTest.kt index 37f75add4b..b8b4e98451 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragmentTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragmentTest.kt @@ -16,15 +16,20 @@ package com.google.android.fhir.datacapture +import android.os.Bundle import android.widget.TextView +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.FragmentFactory +import androidx.fragment.app.testing.launchFragment import androidx.fragment.app.testing.launchFragmentInContainer import androidx.fragment.app.testing.withFragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.test.platform.app.InstrumentationRegistry import com.google.android.fhir.datacapture.validation.QuestionnaireResponseValidator import com.google.common.truth.Truth.assertThat +import kotlin.test.assertEquals import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -75,6 +80,93 @@ class QuestionnaireValidationErrorMessageDialogFragmentTest { } } + @Test + fun `check alertDialog when submit anyway button argument is true should show Submit anyway button`() { + runTest { + val questionnaireValidationErrorMessageDialogArguments = Bundle() + questionnaireValidationErrorMessageDialogArguments.putBoolean( + QuestionnaireValidationErrorMessageDialogFragment.EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON, + true, + ) + with( + launchFragment( + themeResId = R.style.Theme_Questionnaire, + fragmentArgs = questionnaireValidationErrorMessageDialogArguments, + ), + ) { + onFragment { fragment -> + assertThat(fragment.dialog).isNotNull() + assertThat(fragment.requireDialog().isShowing).isTrue() + val alertDialog = fragment.dialog as? AlertDialog + val context = InstrumentationRegistry.getInstrumentation().targetContext + val positiveButtonText = + context.getString(R.string.questionnaire_validation_error_fix_button_text) + val negativeButtonText = + context.getString(R.string.questionnaire_validation_error_submit_button_text) + assertThat(alertDialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.text) + .isEqualTo(positiveButtonText) + assertThat(alertDialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.text) + .isEqualTo(negativeButtonText) + } + } + } + } + + @Test + fun `check alertDialog when no arguments are passed should show Submit anyway button`() { + runTest { + with( + launchFragment( + themeResId = R.style.Theme_Questionnaire, + ), + ) { + onFragment { fragment -> + assertThat(fragment.dialog).isNotNull() + assertThat(fragment.requireDialog().isShowing).isTrue() + val alertDialog = fragment.dialog as? AlertDialog + val context = InstrumentationRegistry.getInstrumentation().targetContext + val positiveButtonText = + context.getString(R.string.questionnaire_validation_error_fix_button_text) + val negativeButtonText = + context.getString(R.string.questionnaire_validation_error_submit_button_text) + assertThat(alertDialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.text) + .isEqualTo(positiveButtonText) + assertThat(alertDialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.text) + .isEqualTo(negativeButtonText) + } + } + } + } + + @Test + fun `check alertDialog when submit anyway button argument is false should hide Submit anyway button`() { + runTest { + val validationErrorBundle = Bundle() + validationErrorBundle.putBoolean( + QuestionnaireValidationErrorMessageDialogFragment.EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON, + false, + ) + with( + launchFragment( + themeResId = R.style.Theme_Questionnaire, + fragmentArgs = validationErrorBundle, + ), + ) { + onFragment { fragment -> + assertThat(fragment.dialog).isNotNull() + assertThat(fragment.requireDialog().isShowing).isTrue() + val alertDialog = fragment.dialog as? AlertDialog + val context = InstrumentationRegistry.getInstrumentation().targetContext + val positiveButtonText = + context.getString(R.string.questionnaire_validation_error_fix_button_text) + assertThat(alertDialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.text) + .isEqualTo(positiveButtonText) + assertEquals(alertDialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.text, "") + } + } + } + } + private suspend fun createTestValidationErrorViewModel( questionnaire: Questionnaire, questionnaireResponse: QuestionnaireResponse, From 81d293ef700dff7717a6c0ddfd42742ba1f4e356 Mon Sep 17 00:00:00 2001 From: aditya-07 Date: Fri, 4 Oct 2024 18:28:30 +0530 Subject: [PATCH 05/17] Activity flow api changes. (#2476) * Base classes for the activity flow api * Updated the api * spotless * Added fluent api * Cleanded test * Added more activities * Updated to a new flow style * Updated the flow and added CPG structures * Added request and event classes. Updated the flow for event type when perform is called * Cleanup of activity flow files * Added docs, updated tests * Updated docs * Refactored code , defined new api and separated the phase and resource classes * spotless * Updated kdocs, checks and error messages * Review comments + kdoc * Added tests * Updated some code logic and kdocs * Refactored code * Review comments: Renamed api, removed classes not related to current supported activities * Updated test case --- workflow/build.gradle.kts | 1 + .../fhir/workflow/activity/ActivityFlow.kt | 307 +++++++++ .../fhir/workflow/activity/phase/Phase.kt | 79 +++ .../activity/phase/event/PerformPhase.kt | 216 ++++++ .../phase/request/BaseRequestPhase.kt | 96 +++ .../activity/phase/request/OrderPhase.kt | 131 ++++ .../activity/phase/request/PlanPhase.kt | 129 ++++ .../activity/phase/request/ProposalPhase.kt | 38 ++ .../resource/event/CPGCommunicationEvent.kt | 71 ++ .../resource/event/CPGEventResource.kt | 113 +++ .../event/CPGMedicationDispenseEvent.kt | 102 +++ .../resource/event/CPGOrderMedicationEvent.kt | 34 + .../resource/event/EventStatusCodeMapper.kt | 69 ++ .../request/CPGCommunicationRequest.kt | 61 ++ .../resource/request/CPGMedicationRequest.kt | 64 ++ .../resource/request/CPGRequestResource.kt | 194 ++++++ .../resource/request/StatusCodeMapper.kt | 64 ++ .../workflow/activity/ActivityFlowTest.kt | 645 ++++++++++++++++++ 18 files changed, 2414 insertions(+) create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/ActivityFlow.kt create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/Phase.kt create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/event/PerformPhase.kt create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/BaseRequestPhase.kt create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/OrderPhase.kt create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/PlanPhase.kt create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/ProposalPhase.kt create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGCommunicationEvent.kt create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGEventResource.kt create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGMedicationDispenseEvent.kt create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGOrderMedicationEvent.kt create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/EventStatusCodeMapper.kt create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/CPGCommunicationRequest.kt create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/CPGMedicationRequest.kt create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/CPGRequestResource.kt create mode 100644 workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/StatusCodeMapper.kt create mode 100644 workflow/src/test/java/com/google/android/fhir/workflow/activity/ActivityFlowTest.kt diff --git a/workflow/build.gradle.kts b/workflow/build.gradle.kts index 3d2696e944..2f9c2688e3 100644 --- a/workflow/build.gradle.kts +++ b/workflow/build.gradle.kts @@ -113,6 +113,7 @@ dependencies { testImplementation(libs.androidx.room.runtime) testImplementation(libs.androidx.test.core) testImplementation(libs.junit) + testImplementation(libs.kotlin.test.junit) testImplementation(libs.truth) testImplementation(project(":workflow-testing")) testImplementation(project(":knowledge")) diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/ActivityFlow.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/ActivityFlow.kt new file mode 100644 index 0000000000..ede47ac171 --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/ActivityFlow.kt @@ -0,0 +1,307 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity + +import androidx.annotation.WorkerThread +import com.google.android.fhir.workflow.activity.phase.Phase +import com.google.android.fhir.workflow.activity.phase.Phase.PhaseName +import com.google.android.fhir.workflow.activity.phase.Phase.PhaseName.ORDER +import com.google.android.fhir.workflow.activity.phase.Phase.PhaseName.PERFORM +import com.google.android.fhir.workflow.activity.phase.Phase.PhaseName.PLAN +import com.google.android.fhir.workflow.activity.phase.Phase.PhaseName.PROPOSAL +import com.google.android.fhir.workflow.activity.phase.event.PerformPhase +import com.google.android.fhir.workflow.activity.phase.request.OrderPhase +import com.google.android.fhir.workflow.activity.phase.request.PlanPhase +import com.google.android.fhir.workflow.activity.phase.request.ProposalPhase +import com.google.android.fhir.workflow.activity.resource.event.CPGCommunicationEvent +import com.google.android.fhir.workflow.activity.resource.event.CPGEventResource +import com.google.android.fhir.workflow.activity.resource.event.CPGOrderMedicationEvent +import com.google.android.fhir.workflow.activity.resource.request.CPGCommunicationRequest +import com.google.android.fhir.workflow.activity.resource.request.CPGMedicationRequest +import com.google.android.fhir.workflow.activity.resource.request.CPGRequestResource +import com.google.android.fhir.workflow.activity.resource.request.Intent +import org.opencds.cqf.fhir.api.Repository + +/** + * Manages the workflow of clinical recommendations according to the FHIR Clinical Practice + * Guidelines (CPG) specification. This class implements an + * [activity flow](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#activity-lifecycle---request-phases-proposal-plan-order), + * allowing you to take proposals and guide them through various phases (proposal, plan, order, + * perform) of a clinical recommendation. You can also resume existing workflows from any phase. + * + * **NOTE** + * * The `prepare` and `initiate` apis of `ActivityFlow` and all apis of `Phase` interface may block + * the caller thread and should only be called from a worker thread. + * * The `ActivityFlow` is not thread safe and concurrent changes to the flow/phase with multiple + * threads may produce undesired results. + * + * **Creating an ActivityFlow:** + * + * Use appropriate `ActivityFlow.of()` factory function to create an instance. You can start a new + * flow with a `CPGRequestResource` or resume an existing flow from a `CPGRequestResource` or + * `CPGEventResource` based on the last state of the flow. + * + * ``` kotlin + * val request = CPGMedicationRequest(medicationRequestGeneratedByCarePlan) + * val flow = ActivityFlow.of(repository, request) + * ``` + * + * **Navigating Phases:** + * + * An `ActivityFlow` progresses through a series of phases, represented by the `Phase` class. You + * can access the current phase using `getCurrentPhase()`. + * + * ``` kotlin + * when (val phase = flow.getCurrentPhase( ) ) { + * is Phase.ProposalPhase -> // Handle proposal phase + * is Phase.PlanPhase -> // Handle plan phase + * is Phase.OrderPhase -> // Handle order phase + * is Phase.PerformPhase -> // Handle perform phase + * } + * ``` + * + * **Transitioning Between Phases:** + * + * [ActivityFlow] provides functions to prepare and initiate the next phase. + * * The prepare api creates a new request or event based on the phase and returns it back to you. + * It doesn't make any changes to the current phase request and also doesn't persist anything to + * the [repository]. + * * The initiate api creates a new phase based on the current phase and provided request/event. It + * does make changes to the current phase request and the provided request and persists them to + * the [repository]. For example, to move from the proposal phase to the plan phase: + * ``` kotlin + * val preparePlanResult = flow.getCurrentPhase( ).preparePlan() + * if (preparePlanResult.isFailure) { + * // Handle failure + * } + * + * val preparedPlan = preparePlanResult.getOrThrow() + * // ... modify preparedPlan + * val planPhase = flow.getCurrentPhase().initiatePlan(preparedPlan) + * ``` + * + * **Note:** The specific `prepare` and `initiate` functions available depend on the current phase. + * + * **Transitioning to Perform Phase:** + * + * Since the perform creates a [CPGEventResource] and the same flow could create different event + * resources, you need to provide the appropriate event type as a parameter to the [preparePerform]. + * + * Example: + * ``` kotlin + * // Prepare and initiate the perform phase + * val preparedPerformEvent = flow.getCurrentPhase().preparePerform(CPGMedicationDispenseEvent::class.java) . getOrThrow( ) + * // update preparedPerformEvent + * val performPhase = flow.getCurrentPhase( ) . initiatePerform(preparedPerformEvent) . getOrThrow( ) + * ``` + * + * **Updating states in a phase:** + * + * `ProposalPhase`, `PlanPhase` and `OrderPhase` are all a type of `Phase.RequestPhase` and allows + * you to update state of the request. + * + * ``` kotlin + * val planPhase = flow.getCurrentPhase().initiatePlan(preparedPlan) + * val medicationRequest = planPhase.getRequestResource() + * // update medicationRequest + * planPhase.update(updated medicationRequest) + * ``` + * + * `PerformPhase` is a type of `Phase.EventPhase` and allows you to update the state of the event. + * + * ``` kotlin + * val performPhase = ... + * val medicationDispense = performPhase.getEventResource() + * // update medicationDispense + * performPhase.update(updated medicationDispense) + * performPhase.complete() + * ``` + */ +@Suppress( + "UnstableApiUsage", /* Repository is marked @Beta */ +) +class ActivityFlow, E : CPGEventResource<*>> +private constructor( + private val repository: Repository, + requestResource: R? = null, + eventResource: E? = null, +) { + + private var currentPhase: Phase + + init { + currentPhase = + if (eventResource != null) { + PerformPhase(repository, eventResource) + } else if (requestResource != null) { + when (requestResource.getIntent()) { + Intent.PROPOSAL -> ProposalPhase(repository, requestResource) + Intent.PLAN -> PlanPhase(repository, requestResource) + Intent.ORDER -> OrderPhase(repository, requestResource) + else -> + throw IllegalArgumentException( + "Couldn't create the flow for ${requestResource.getIntent()} intent. Supported intents are 'proposal', 'plan' and 'order'.", + ) + } + } else { + throw IllegalArgumentException( + "Either Request or Event is required to create a flow. Both can't be null.", + ) + } + } + + /** + * Returns the current phase of the flow. The users may check the type of flow by calling + * [Phase.getPhaseName] on the [getCurrentPhase] and then cast it to appropriate classes. + * + * The table below shows the mapping between the [PhaseName] and [Phase] implementations. + * + * | [PhaseName] | [Class] | + * |-------------|-----------------| + * | [PROPOSAL] | [ProposalPhase] | + * | [PLAN] | [PlanPhase] | + * | [ORDER] | [OrderPhase] | + * | [PERFORM] | [PerformPhase] | + */ + fun getCurrentPhase(): Phase { + return currentPhase + } + + /** + * Prepares a plan resource based on the state of the [currentPhase] and returns it to the caller + * without persisting any changes into [repository]. + * + * @return [Result]<[R]> containing plan if the action is successful, error otherwise. + */ + @WorkerThread + fun preparePlan(): Result { + return PlanPhase.prepare(currentPhase) + } + + /** + * Initiates a plan phase based on the state of the [currentPhase] and [preparedPlan]. This api + * will persist the [preparedPlan] into [repository]. + * + * @return [PlanPhase] if the action is successful, error otherwise. + */ + @WorkerThread + fun initiatePlan(preparedPlan: R) = + PlanPhase.initiate(repository, currentPhase, preparedPlan).also { + it.onSuccess { currentPhase = it } + } + + /** + * Prepares an order resource based on the state of the [currentPhase] and returns it to the + * caller without persisting any changes into [repository]. + * + * @return [Result]<[R]> containing order if the action is successful, error otherwise. + */ + @WorkerThread + fun prepareOrder(): Result { + return OrderPhase.prepare(currentPhase) + } + + /** + * Initiates an order phase based on the state of the [currentPhase] and [preparePlan]. This api + * will persist the [preparedOrder] into [repository]. + * + * @return [OrderPhase] if the action is successful, error otherwise. + */ + @WorkerThread + fun initiateOrder(preparedOrder: R) = + OrderPhase.initiate(repository, currentPhase, preparedOrder).also { + it.onSuccess { currentPhase = it } + } + + /** + * Prepares an event resource based on the state of the [currentPhase] and returns it to the + * caller without persisting any changes into [repository]. + * + * @return [Result]<[D]> containing event if the action is successful, error otherwise. + */ + @WorkerThread + fun preparePerform(klass: Class): Result { + return PerformPhase.prepare(klass, currentPhase) + } + + /** + * Initiate a perform phase based on the state of the [currentPhase] and [preparePlan]. This api + * will persist the [preparedEvent] into [repository]. + * + * @return [PerformPhase] if the action is successful, error otherwise. + */ + @WorkerThread + fun initiatePerform(preparedEvent: D) = + PerformPhase.initiate(repository, currentPhase, preparedEvent).also { + it.onSuccess { currentPhase = it } + } + + companion object { + + /** + * Create flow for the + * [Send Message](https://build.fhir.org/ig/HL7/cqf-recommendations/examples-activities.html#send-a-message) + * activity with the [CPGCommunicationRequest]. + * + * @return ActivityFlow + */ + fun of( + repository: Repository, + resource: CPGCommunicationRequest, + ): ActivityFlow = + ActivityFlow(repository, resource) + + /** + * Create flow for the + * [Send Message](https://build.fhir.org/ig/HL7/cqf-recommendations/examples-activities.html#send-a-message) + * activity with the [CPGCommunicationEvent]. + * + * @return ActivityFlow + */ + fun of( + repository: Repository, + resource: CPGCommunicationEvent, + ): ActivityFlow = + ActivityFlow(repository, null, resource) + + /** + * Create flow for the + * [Order a medication](https://build.fhir.org/ig/HL7/cqf-recommendations/examples-activities.html#order-a-medication) + * activity with the [CPGMedicationRequest]. + * + * @return ActivityFlow> + */ + fun of( + repository: Repository, + resource: CPGMedicationRequest, + ): ActivityFlow> = + ActivityFlow(repository, resource) + + /** + * Create flow for the + * [Order a medication](https://build.fhir.org/ig/HL7/cqf-recommendations/examples-activities.html#order-a-medication) + * activity with the [CPGOrderMedicationEvent]. + * + * @return ActivityFlow> + */ + fun of( + repository: Repository, + resource: CPGOrderMedicationEvent<*>, + ): ActivityFlow> = + ActivityFlow(repository, null, resource) + } +} diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/Phase.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/Phase.kt new file mode 100644 index 0000000000..a11ce85e23 --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/Phase.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity.phase + +import androidx.annotation.WorkerThread +import com.google.android.fhir.workflow.activity.resource.event.CPGEventResource +import com.google.android.fhir.workflow.activity.resource.request.CPGRequestResource +import org.hl7.fhir.r4.model.IdType +import org.hl7.fhir.r4.model.Reference + +/** Defines the various phases of a CPG Request. */ +sealed interface Phase { + + enum class PhaseName { + PROPOSAL, + PLAN, + ORDER, + PERFORM, + } + + fun getPhaseName(): PhaseName + + /** Activity Phases for a CPG Request. */ + interface RequestPhase> : Phase { + fun getRequestResource(): R + + @WorkerThread fun update(r: R): Result + + @WorkerThread fun suspend(reason: String?): Result + + @WorkerThread fun resume(): Result + + @WorkerThread fun enteredInError(reason: String?): Result + + @WorkerThread fun reject(reason: String?): Result + } + + /** Activity phases for a CPG Event. */ + interface EventPhase> : Phase { + fun getEventResource(): E + + @WorkerThread fun update(e: E): Result + + @WorkerThread fun suspend(reason: String?): Result + + @WorkerThread fun resume(): Result + + @WorkerThread fun enteredInError(reason: String?): Result + + @WorkerThread fun start(): Result + + @WorkerThread fun notDone(reason: String?): Result + + @WorkerThread fun stop(reason: String?): Result + + @WorkerThread fun complete(): Result + } +} + +/** Checks if two references are equal by equating their value. */ +internal fun checkEquals(a: Reference, b: Reference) = a.reference == b.reference + +/** Returns an [IdType] of a [Reference]. This is required for [Repository.read] api. */ +internal val Reference.idType + get() = IdType(reference) diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/event/PerformPhase.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/event/PerformPhase.kt new file mode 100644 index 0000000000..168c95bdca --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/event/PerformPhase.kt @@ -0,0 +1,216 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity.phase.event + +import com.google.android.fhir.getResourceClass +import com.google.android.fhir.workflow.activity.phase.Phase +import com.google.android.fhir.workflow.activity.phase.checkEquals +import com.google.android.fhir.workflow.activity.phase.idType +import com.google.android.fhir.workflow.activity.phase.request.BaseRequestPhase +import com.google.android.fhir.workflow.activity.resource.event.CPGEventResource +import com.google.android.fhir.workflow.activity.resource.event.EventStatus +import com.google.android.fhir.workflow.activity.resource.request.CPGRequestResource +import com.google.android.fhir.workflow.activity.resource.request.Intent +import com.google.android.fhir.workflow.activity.resource.request.Status +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.Resource +import org.opencds.cqf.fhir.api.Repository + +/** + * Provides implementation of the perform phase of the activity flow. See + * [general-activity-flow](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#general-activity-flow) + * for more info. + */ +@Suppress( + "UnstableApiUsage", /* Repository is marked @Beta */ + "UNCHECKED_CAST", /* Cast type erased CPGRequestResource<*> & CPGEventResource<*> to a concrete type classes */ +) +class PerformPhase>( + /** Implementation of [Repository] to store / retrieve FHIR resources. */ + private val repository: Repository, + /** Concrete implementation of sealed [CPGEventResource] class. e.g. `CPGCommunicationEvent`. */ + e: E, +) : Phase.EventPhase { + private var event: E = e.copy() as E + + override fun getPhaseName() = Phase.PhaseName.PERFORM + + override fun getEventResource() = event.copy() as E + + override fun update(e: E) = + runCatching { + // TODO Add some basic checks to make sure e is update event and not a completely different + // resource. + require(e.getStatus() in listOf(EventStatus.PREPARATION, EventStatus.INPROGRESS)) { + "Status is ${e.getStatusCode()}" + } + repository.update(e.resource) + event = e + } + + override fun suspend(reason: String?) = + runCatching { + check(event.getStatus() == EventStatus.INPROGRESS) { + " Can't suspend an event with status ${event.getStatusCode()} " + } + + event.setStatus(EventStatus.ONHOLD, reason) + repository.update(event.resource) + } + + override fun resume() = + runCatching { + check(event.getStatus() == EventStatus.ONHOLD) { + " Can't resume an event with status ${event.getStatusCode()} " + } + + event.setStatus(EventStatus.INPROGRESS) + repository.update(event.resource) + } + + override fun enteredInError(reason: String?) = + runCatching { + event.setStatus(EventStatus.ENTEREDINERROR, reason) + repository.update(event.resource) + } + + override fun start() = + runCatching { + check(event.getStatus() == EventStatus.PREPARATION) { + " Can't start an event with status ${event.getStatusCode()} " + } + + event.setStatus(EventStatus.INPROGRESS) + repository.update(event.resource) + } + + override fun notDone(reason: String?) = + runCatching { + check(event.getStatus() == EventStatus.PREPARATION) { + " Can't not-done an event with status ${event.getStatusCode()} " + } + + event.setStatus(EventStatus.NOTDONE, reason) + repository.update(event.resource) + } + + override fun stop(reason: String?) = + runCatching { + check(event.getStatus() == EventStatus.INPROGRESS) { + " Can't stop an event with status ${event.getStatusCode()} " + } + + event.setStatus(EventStatus.STOPPED, reason) + repository.update(event.resource) + } + + override fun complete() = + runCatching { + check(event.getStatus() == EventStatus.INPROGRESS) { + " Can't complete an event with status ${event.getStatusCode()} " + } + + event.setStatus(EventStatus.COMPLETED) + repository.update(event.resource) + } + + companion object { + + private val AllowedIntents = listOf(Intent.PROPOSAL, Intent.PLAN, Intent.ORDER) + private val AllowedPhases = + listOf(Phase.PhaseName.PROPOSAL, Phase.PhaseName.PLAN, Phase.PhaseName.ORDER) + private val AllowedStatusForPhaseStart = listOf(EventStatus.INPROGRESS, EventStatus.PREPARATION) + + /** + * Returns the [Resource] class for the resource. e.g. If the Reference is `Patient/1234`, then + * this would return the `Class` for `org.hl7.fhir.r4.model.Patient`. + */ + private val Reference.`class` + get() = getResourceClass(reference.split("/")[0]) + + /** + * Creates a draft event of type [E] based on the state of the provided [inputPhase]. See + * [beginPerform](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#perform) + * for more details. + */ + fun , E : CPGEventResource<*>> prepare( + eventClass: Class<*>, + inputPhase: Phase, + ): Result = runCatching { + check(inputPhase.getPhaseName() in AllowedPhases) { + "Event can't be created for a flow in ${inputPhase.getPhaseName().name} phase. " + } + + val inputRequest = (inputPhase as BaseRequestPhase<*>).request + + check(inputRequest.getIntent() in AllowedIntents) { + "Event can't be created for a request with ${inputRequest.getIntent()} intent." + } + + check(inputRequest.getStatus() == Status.ACTIVE) { + "${inputPhase.getPhaseName().name} request is still in ${inputRequest.getStatusCode()} status." + } + + val eventRequest = CPGEventResource.of(inputRequest, eventClass) + eventRequest.setStatus(EventStatus.PREPARATION) + eventRequest.setBasedOn(inputRequest.asReference()) + eventRequest as E + } + + fun , E : CPGEventResource<*>> initiate( + repository: Repository, + inputPhase: Phase, + inputEvent: E, + ): Result> = runCatching { + check(inputPhase.getPhaseName() in AllowedPhases) { + "A Perform can't be started for a flow in ${inputPhase.getPhaseName().name} phase." + } + + val currentPhase = inputPhase as BaseRequestPhase<*> + + val basedOn = inputEvent.getBasedOn() + require(basedOn != null) { "${inputEvent.resource.resourceType}.basedOn can't be null." } + + require(checkEquals(basedOn, currentPhase.request.asReference())) { + "Provided draft is not based on the request in current phase." + } + + val basedOnRequest = + repository.read(basedOn.`class`, basedOn.idType)?.let { CPGRequestResource.of(it) } + + require(basedOnRequest != null) { "Couldn't find ${basedOn.reference} in the database." } + + require(basedOnRequest.getIntent() in AllowedIntents) { + "Order can't be based on a request with ${basedOnRequest.getIntent()} intent." + } + + require(basedOnRequest.getStatus() == Status.ACTIVE) { + "Plan can't be based on a request with ${basedOnRequest.getStatusCode()} status." + } + + require(inputEvent.getStatus() in AllowedStatusForPhaseStart) { + "Input event is in ${inputEvent.getStatusCode()} status." + } + + basedOnRequest.setStatus(Status.COMPLETED) + + repository.create(inputEvent.resource) + repository.update(basedOnRequest.resource) + PerformPhase(repository, inputEvent) + } + } +} diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/BaseRequestPhase.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/BaseRequestPhase.kt new file mode 100644 index 0000000000..b253df6ead --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/BaseRequestPhase.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity.phase.request + +import com.google.android.fhir.workflow.activity.phase.Phase +import com.google.android.fhir.workflow.activity.resource.request.CPGRequestResource +import com.google.android.fhir.workflow.activity.resource.request.Status +import org.opencds.cqf.fhir.api.Repository + +/** Encapsulates the state transitions of a [Phase.RequestPhase]. */ +@Suppress( + "UnstableApiUsage", /* Repository is marked @Beta */ +) +abstract class BaseRequestPhase>( + /** Implementation of [Repository] to store / retrieve FHIR resources. */ + private val repository: Repository, + /** + * Concrete implementation of sealed [CPGRequestResource] class. e.g. `CPGCommunicationRequest`. + */ + r: R, + /** PhaseName of the concrete implementation. */ + private val phaseName: Phase.PhaseName, +) : Phase.RequestPhase { + internal var request: R = r.copy() as R + + // TODO : Maybe this should return a copy of the resource so that if the user does any changes to + // this resource, it doesn't affect the state of the flow. + override fun getRequestResource() = request.copy() as R + + override fun getPhaseName() = phaseName + + override fun suspend(reason: String?) = + runCatching { + check(request.getStatus() == Status.ACTIVE) { + " Can't suspend an event with status ${request.getStatusCode()} " + } + + request.setStatus(Status.ONHOLD, reason) + repository.update(request.resource) + } + + override fun resume() = + runCatching { + check(request.getStatus() == Status.ONHOLD) { + " Can't resume an event with status ${request.getStatusCode()} " + } + + request.setStatus(Status.ACTIVE) + repository.update(request.resource) + } + + override fun update(r: R) = + runCatching { + // TODO Add some basic checks to make sure e is update event and not a completely different + // resource. + require(r.getStatus() in listOf(Status.DRAFT, Status.ACTIVE)) { + "Status is ${r.getStatusCode()}" + } + repository.update(r.resource) + request = r + } + + override fun enteredInError(reason: String?) = + runCatching { + request.setStatus(Status.ENTEREDINERROR, reason) + repository.update(request.resource) + } + + override fun reject(reason: String?) = + runCatching { + check(request.getStatus() == Status.ACTIVE) { + " Can't reject an event with status ${request.getStatusCode()} " + } + + request.setStatus(Status.REVOKED, reason) + repository.update(request.resource) + } + + companion object { + val AllowedStatusForPhaseStart = listOf(Status.DRAFT, Status.ACTIVE) + } +} diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/OrderPhase.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/OrderPhase.kt new file mode 100644 index 0000000000..c9e21b1598 --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/OrderPhase.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity.phase.request + +import com.google.android.fhir.workflow.activity.phase.Phase +import com.google.android.fhir.workflow.activity.phase.checkEquals +import com.google.android.fhir.workflow.activity.phase.idType +import com.google.android.fhir.workflow.activity.resource.request.CPGRequestResource +import com.google.android.fhir.workflow.activity.resource.request.Intent +import com.google.android.fhir.workflow.activity.resource.request.Status +import java.util.UUID +import org.opencds.cqf.fhir.api.Repository + +/** + * Provides implementation of the order phase of the activity flow. See + * [general-activity-flow](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#general-activity-flow) + * for more info. + */ +@Suppress( + "UnstableApiUsage", /* Repository is marked @Beta */ + "UNCHECKED_CAST", /* Cast type erased CPGRequestResource<*> & CPGEventResource<*> to a concrete type classes */ +) +class OrderPhase>( + /** Implementation of [Repository] to store / retrieve FHIR resources. */ + repository: Repository, + /** + * Concrete implementation of sealed [CPGRequestResource] class. e.g. `CPGCommunicationRequest`. + */ + r: R, +) : BaseRequestPhase(repository, r, Phase.PhaseName.ORDER) { + + companion object { + + private val AllowedIntents = listOf(Intent.PROPOSAL, Intent.PLAN) + private val AllowedPhases = listOf(Phase.PhaseName.PROPOSAL, Phase.PhaseName.PLAN) + + /** + * Creates a draft order of type [R] based on the state of the provided [inputPhase]. See + * [beginOrder](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#order) for + * more details. + */ + internal fun > prepare(inputPhase: Phase): Result = runCatching { + check(inputPhase.getPhaseName() in AllowedPhases) { + "An Order can't be created for a flow in ${inputPhase.getPhaseName().name} phase. " + } + + val inputRequest = (inputPhase as BaseRequestPhase<*>).request + + check(inputRequest.getIntent() in AllowedIntents) { + "Order can't be created for a request with ${inputRequest.getIntent()} intent." + } + + check(inputRequest.getStatus() == Status.ACTIVE) { + "${inputPhase.getPhaseName().name} request is still in ${inputRequest.getStatusCode()} status." + } + + inputRequest.copy( + UUID.randomUUID().toString(), + Status.DRAFT, + Intent.ORDER, + ) as R + } + + /** + * Creates a [OrderPhase] of request type [R] based on the [inputPhase] and [inputOrder]. See + * [endPlan](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#plan) for more + * details. + */ + fun > initiate( + repository: Repository, + inputPhase: Phase, + inputOrder: R, + ): Result> = runCatching { + check(inputPhase.getPhaseName() in AllowedPhases) { + "An Order can't be started for a flow in ${inputPhase.getPhaseName().name} phase." + } + + val currentPhase = inputPhase as BaseRequestPhase<*> + + val basedOn = inputOrder.getBasedOn() + require(basedOn != null) { "${inputOrder.resource.resourceType}.basedOn can't be null." } + + require(checkEquals(basedOn, currentPhase.request.asReference())) { + "Provided draft is not based on the request in current phase." + } + + val basedOnRequest = + repository.read(inputOrder.resource.javaClass, basedOn.idType)?.let { + CPGRequestResource.of(inputOrder, it) + } + + require(basedOnRequest != null) { "Couldn't find ${basedOn.reference} in the database." } + + require(basedOnRequest.getIntent() in AllowedIntents) { + "Order can't be based on a request with ${basedOnRequest.getIntent()} intent." + } + + require(basedOnRequest.getStatus() == Status.ACTIVE) { + "Plan can't be based on a request with ${basedOnRequest.getStatusCode()} status." + } + + require(inputOrder.getIntent() == Intent.ORDER) { + "Input request has '${inputOrder.getIntent()}' intent." + } + + require(inputOrder.getStatus() in AllowedStatusForPhaseStart) { + "Input request is in ${inputOrder.getStatusCode()} status." + } + + basedOnRequest.setStatus(Status.COMPLETED) + + repository.create(inputOrder.resource) + repository.update(basedOnRequest.resource) + OrderPhase(repository, inputOrder) + } + } +} diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/PlanPhase.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/PlanPhase.kt new file mode 100644 index 0000000000..bb02f0e79c --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/PlanPhase.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity.phase.request + +import com.google.android.fhir.workflow.activity.phase.Phase +import com.google.android.fhir.workflow.activity.phase.checkEquals +import com.google.android.fhir.workflow.activity.phase.idType +import com.google.android.fhir.workflow.activity.resource.request.CPGRequestResource +import com.google.android.fhir.workflow.activity.resource.request.Intent +import com.google.android.fhir.workflow.activity.resource.request.Status +import java.util.UUID +import org.opencds.cqf.fhir.api.Repository + +/** + * Provides implementation of the plan phase of the activity flow. See + * [general-activity-flow](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#general-activity-flow) + * for more info. + */ +@Suppress( + "UnstableApiUsage", /* Repository is marked @Beta */ + "UNCHECKED_CAST", /* Cast type erased CPGRequestResource<*> & CPGEventResource<*> to a concrete type classes */ +) +class PlanPhase>( + /** Implementation of [Repository] to store / retrieve FHIR resources. */ + repository: Repository, + /** + * Concrete implementation of sealed [CPGRequestResource] class. e.g. `CPGCommunicationRequest`. + */ + r: R, +) : BaseRequestPhase(repository, r, Phase.PhaseName.PLAN) { + + companion object { + + /** + * Creates a draft plan of type [R] based on the state of the provided [inputPhase]. See + * [beginPlan](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#plan) for + * more details. + */ + internal fun > prepare(inputPhase: Phase): Result = runCatching { + check(inputPhase.getPhaseName() == Phase.PhaseName.PROPOSAL) { + "A Plan can't be created for a flow in ${inputPhase.getPhaseName().name} phase." + } + + val inputRequest = (inputPhase as BaseRequestPhase<*>).request + check(inputRequest.getIntent() == Intent.PROPOSAL) { + "Plan can't be created for a request with ${inputRequest.getIntent()} intent." + } + + check(inputRequest.getStatus() == Status.ACTIVE) { + "${inputPhase.getPhaseName().name} request is still in ${inputRequest.getStatusCode()} status." + } + + val planRequest: CPGRequestResource<*> = + inputRequest.copy( + id = UUID.randomUUID().toString(), + status = Status.DRAFT, + intent = Intent.PLAN, + ) + + planRequest as R + } + + /** + * Creates a [PlanPhase] of request type [R] based on the [inputPhase] and [draftPlan]. See + * [endPlan](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#plan) for more + * details. + */ + internal fun > initiate( + repository: Repository, + inputPhase: Phase, + draftPlan: R, + ): Result> = runCatching { + check(inputPhase.getPhaseName() == Phase.PhaseName.PROPOSAL) { + "A Plan can't be started for a flow in ${inputPhase.getPhaseName().name} phase." + } + + val currentPhase = inputPhase as BaseRequestPhase<*> + + val basedOn = draftPlan.getBasedOn() + require(basedOn != null) { "${draftPlan.resource.resourceType}.basedOn can't be null." } + + require(checkEquals(basedOn, currentPhase.request.asReference())) { + "Provided draft is not based on the request in current phase." + } + + val basedOnRequest = + repository.read(draftPlan.resource.javaClass, basedOn.idType)?.let { + CPGRequestResource.of(draftPlan, it) + } + require(basedOnRequest != null) { "Couldn't find ${basedOn.reference} in the database." } + + require(basedOnRequest.getIntent() == Intent.PROPOSAL) { + "Plan can't be based on a request with ${basedOnRequest.getIntent()} intent." + } + + require(basedOnRequest.getStatus() == Status.ACTIVE) { + "Plan can't be based on a request with ${basedOnRequest.getStatusCode()} status." + } + + require(draftPlan.getIntent() == Intent.PLAN) { + "Input request has '${draftPlan.getIntent()}' intent." + } + + require(draftPlan.getStatus() in AllowedStatusForPhaseStart) { + "Input request is in ${draftPlan.getStatusCode()} status." + } + + basedOnRequest.setStatus(Status.COMPLETED) + + repository.create(draftPlan.resource) + repository.update(basedOnRequest.resource) + PlanPhase(repository, draftPlan) + } + } +} diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/ProposalPhase.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/ProposalPhase.kt new file mode 100644 index 0000000000..59bb12e9e6 --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/phase/request/ProposalPhase.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity.phase.request + +import com.google.android.fhir.workflow.activity.phase.Phase +import com.google.android.fhir.workflow.activity.resource.request.CPGRequestResource +import org.opencds.cqf.fhir.api.Repository + +/** + * Provides implementation of the proposal phase of the activity flow. See + * [general-activity-flow](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#general-activity-flow) + * for more info. + */ +@Suppress( + "UnstableApiUsage", /* Repository is marked @Beta */ +) +class ProposalPhase>( + /** Implementation of [Repository] to store / retrieve FHIR resources. */ + repository: Repository, + /** + * Concrete implementation of sealed [CPGRequestResource] class. e.g. `CPGCommunicationRequest`. + */ + r: R, +) : BaseRequestPhase(repository, r, Phase.PhaseName.PROPOSAL) diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGCommunicationEvent.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGCommunicationEvent.kt new file mode 100644 index 0000000000..b5d2d4f90c --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGCommunicationEvent.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity.resource.event + +import com.google.android.fhir.workflow.activity.resource.event.EventStatus.PREPARATION +import com.google.android.fhir.workflow.activity.resource.request.CPGCommunicationRequest +import java.util.UUID +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Communication +import org.hl7.fhir.r4.model.Reference + +class CPGCommunicationEvent(override val resource: Communication) : + CPGEventResource(resource, CommunicationEventMapper) { + + override fun setStatus(status: EventStatus, reason: String?) { + resource.status = Communication.CommunicationStatus.fromCode(mapper.mapStatusToCode(status)) + resource.statusReason = reason?.let { CodeableConcept(Coding().setCode(it)) } + } + + override fun getStatusCode() = resource.status?.toCode() + + override fun setBasedOn(reference: Reference) { + resource.addBasedOn(reference) + } + + override fun getBasedOn(): Reference? = resource.basedOn.lastOrNull() + + override fun copy() = CPGCommunicationEvent(resource.copy()) + + companion object { + fun from(request: CPGCommunicationRequest): CPGCommunicationEvent { + return CPGCommunicationEvent( + Communication().apply { + id = UUID.randomUUID().toString() + status = Communication.CommunicationStatus.PREPARATION + category = request.resource.category + priority = + Communication.CommunicationPriority.fromCode(request.resource.priority?.toCode()) + medium = request.resource.medium + subject = request.resource.subject + about = request.resource.about + encounter = request.resource.encounter + recipient = request.resource.recipient + sender = request.resource.sender + reasonCode = request.resource.reasonCode + reasonReference = request.resource.reasonReference + request.resource.payload.forEach { + addPayload(Communication.CommunicationPayloadComponent(it.content)) + } + }, + ) + } + } + + private object CommunicationEventMapper : EventStatusCodeMapperImpl() +} diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGEventResource.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGEventResource.kt new file mode 100644 index 0000000000..699545c416 --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGEventResource.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity.resource.event + +import com.google.android.fhir.logicalId +import com.google.android.fhir.workflow.activity.resource.event.CPGEventResource.Companion.of +import com.google.android.fhir.workflow.activity.resource.request.CPGCommunicationRequest +import com.google.android.fhir.workflow.activity.resource.request.CPGMedicationRequest +import com.google.android.fhir.workflow.activity.resource.request.CPGRequestResource +import com.google.android.fhir.workflow.activity.resource.request.CPGRequestResource.Companion.of +import org.hl7.fhir.r4.model.Communication +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType + +/** + * This abstracts the + * [CPG Event Resources](https://build.fhir.org/ig/HL7/cqf-recommendations/profiles.html#activity-profiles) + * used in various activities. The various subclasses of [CPGEventResource] act as a wrapper around + * the resource they are derived from and helps with the abstracted properties defined for each + * [CPGEventResource]. e.g. [CPGCommunicationEvent] is a wrapper around the [Communication] and + * helps with its [EventStatus] and basedOn [Reference]s. + * + * Any direct update to the [resource] can be done by using [update] api. + * + * The application users may use appropriate [of] static factories to create the required + * [CPGEventResource]s. + */ +sealed class CPGEventResource( + internal open val resource: R, + internal val mapper: EventStatusCodeMapper, +) where R : Resource { + + val resourceType: ResourceType + get() = resource.resourceType + + val logicalId: String + get() = resource.logicalId + + abstract fun setStatus(status: EventStatus, reason: String? = null) + + fun getStatus(): EventStatus = mapper.mapCodeToStatus(getStatusCode()) + + abstract fun getStatusCode(): String? + + abstract fun setBasedOn(reference: Reference) + + abstract fun getBasedOn(): Reference? + + abstract fun copy(): CPGEventResource + + companion object { + + fun of(request: CPGRequestResource<*>, eventClass: Class<*>): CPGEventResource<*> { + return when (request) { + is CPGCommunicationRequest -> CPGCommunicationEvent.from(request) + is CPGMedicationRequest -> CPGOrderMedicationEvent.from(request, eventClass) + else -> { + throw IllegalArgumentException("Unknown CPG Request type ${request::class}.") + } + } + } + } +} + +sealed interface EventStatus { + data object PREPARATION : EventStatus + + data object INPROGRESS : EventStatus + + data object NOTDONE : EventStatus + + data object ONHOLD : EventStatus + + data object COMPLETED : EventStatus + + data object ENTEREDINERROR : EventStatus + + data object STOPPED : EventStatus + + data object UNKNOWN : EventStatus + + class OTHER(val code: String?) : EventStatus + + companion object { + + fun of(code: String) = + when (code) { + "preparation" -> PREPARATION + "in-progress" -> INPROGRESS + "not-done" -> NOTDONE + "on-hold" -> ONHOLD + "completed" -> COMPLETED + "entered-in-error" -> ENTEREDINERROR + "stopped" -> STOPPED + else -> OTHER(code) + } + } +} diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGMedicationDispenseEvent.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGMedicationDispenseEvent.kt new file mode 100644 index 0000000000..0d04f3d2c0 --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGMedicationDispenseEvent.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity.resource.event + +import com.google.android.fhir.workflow.activity.resource.event.EventStatus.NOTDONE +import com.google.android.fhir.workflow.activity.resource.request.CPGMedicationRequest +import java.util.UUID +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.MedicationDispense +import org.hl7.fhir.r4.model.MedicationRequest +import org.hl7.fhir.r4.model.Reference + +class CPGMedicationDispenseEvent(override val resource: MedicationDispense) : + CPGOrderMedicationEvent(resource, MedicationDispenseEventEventMapper) { + + override fun setStatus(status: EventStatus, reason: String?) { + resource.status = + MedicationDispense.MedicationDispenseStatus.fromCode(mapper.mapStatusToCode(status)) + resource.statusReason = reason?.let { CodeableConcept(Coding().setCode(it)) } + } + + override fun getStatusCode() = resource.status?.toCode() + + override fun setBasedOn(reference: Reference) { + resource.addAuthorizingPrescription(reference) + } + + override fun getBasedOn(): Reference? = resource.authorizingPrescription.lastOrNull() + + override fun copy() = CPGMedicationDispenseEvent(resource.copy()) + + companion object { + + fun from(request: CPGMedicationRequest): CPGMedicationDispenseEvent { + return CPGMedicationDispenseEvent( + MedicationDispense().apply { + id = UUID.randomUUID().toString() + status = MedicationDispense.MedicationDispenseStatus.PREPARATION + + if (request.resource.hasCategory() && request.resource.category.size == 1) { + // Only set category if single, otherwise let application fill it in. + category = request.resource.category.first() + } + + medication = request.resource.medication + subject = request.resource.subject + context = request.resource.encounter + if (request.resource.hasSubstitution()) { + substitution = request.resource.substitution.toMedicationDispenseSubstitutionComponent() + } + note = request.resource.note + dosageInstruction = request.resource.dosageInstruction + detectedIssue = request.resource.detectedIssue + eventHistory = request.resource.eventHistory + }, + ) + } + + private fun MedicationRequest.MedicationRequestSubstitutionComponent + .toMedicationDispenseSubstitutionComponent() = + MedicationDispense.MedicationDispenseSubstitutionComponent().apply { + id = this@toMedicationDispenseSubstitutionComponent.id + extension = this@toMedicationDispenseSubstitutionComponent.extension + modifierExtension = this@toMedicationDispenseSubstitutionComponent.modifierExtension + allowed = this@toMedicationDispenseSubstitutionComponent.allowed + if (this@toMedicationDispenseSubstitutionComponent.hasReason()) { + addReason(this@toMedicationDispenseSubstitutionComponent.reason) + } + } + } +} + +private object MedicationDispenseEventEventMapper : EventStatusCodeMapperImpl() { + override fun mapCodeToStatus(code: String?): EventStatus { + return when (code) { + "cancelled" -> NOTDONE + else -> super.mapCodeToStatus(code) + } + } + + override fun mapStatusToCode(status: EventStatus): String? { + return when (status) { + NOTDONE -> "cancelled" + else -> super.mapStatusToCode(status) + } + } +} diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGOrderMedicationEvent.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGOrderMedicationEvent.kt new file mode 100644 index 0000000000..a4dceb9d6e --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/CPGOrderMedicationEvent.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity.resource.event + +import com.google.android.fhir.workflow.activity.resource.request.CPGMedicationRequest +import org.hl7.fhir.r4.model.Resource + +abstract class CPGOrderMedicationEvent +internal constructor(override val resource: R, mapper: EventStatusCodeMapper) : + CPGEventResource(resource, mapper) { + + companion object { + + fun from(request: CPGMedicationRequest, eventClass: Class<*>) = + when (eventClass) { + CPGMedicationDispenseEvent::class.java -> CPGMedicationDispenseEvent.from(request) + else -> throw IllegalArgumentException(" Unknown Event type $eventClass") + } + } +} diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/EventStatusCodeMapper.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/EventStatusCodeMapper.kt new file mode 100644 index 0000000000..7ae6f05464 --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/event/EventStatusCodeMapper.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity.resource.event + +import com.google.android.fhir.workflow.activity.resource.event.EventStatus.COMPLETED +import com.google.android.fhir.workflow.activity.resource.event.EventStatus.ENTEREDINERROR +import com.google.android.fhir.workflow.activity.resource.event.EventStatus.INPROGRESS +import com.google.android.fhir.workflow.activity.resource.event.EventStatus.NOTDONE +import com.google.android.fhir.workflow.activity.resource.event.EventStatus.ONHOLD +import com.google.android.fhir.workflow.activity.resource.event.EventStatus.OTHER +import com.google.android.fhir.workflow.activity.resource.event.EventStatus.PREPARATION +import com.google.android.fhir.workflow.activity.resource.event.EventStatus.STOPPED +import com.google.android.fhir.workflow.activity.resource.event.EventStatus.UNKNOWN + +/** + * Since event resources may have different code for same status, each [CPGEventResource] should + * provide its own mapper. See + * [columns next to status](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#activity-lifecycle---event-phase) + */ +internal interface EventStatusCodeMapper { + fun mapCodeToStatus(code: String?): EventStatus + + fun mapStatusToCode(status: EventStatus): String? +} + +/** A base implementation where the status and code map each other. */ +internal open class EventStatusCodeMapperImpl : EventStatusCodeMapper { + override fun mapCodeToStatus(code: String?): EventStatus { + return when (code) { + "preparation" -> PREPARATION + "in-progress" -> INPROGRESS + "not-done" -> NOTDONE + "on-hold" -> ONHOLD + "completed" -> COMPLETED + "entered-in-error" -> ENTEREDINERROR + "stopped" -> STOPPED + "unknown" -> UNKNOWN + else -> OTHER(code) + } + } + + override fun mapStatusToCode(status: EventStatus): String? { + return when (status) { + PREPARATION -> "preparation" + INPROGRESS -> "in-progress" + NOTDONE -> "not-done" + ONHOLD -> "on-hold" + COMPLETED -> "completed" + ENTEREDINERROR -> "entered-in-error" + STOPPED -> "stopped" + UNKNOWN -> "unknown" + is OTHER -> status.code + } + } +} diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/CPGCommunicationRequest.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/CPGCommunicationRequest.kt new file mode 100644 index 0000000000..e626b6854f --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/CPGCommunicationRequest.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity.resource.request + +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.CommunicationRequest +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.StringType + +class CPGCommunicationRequest(override val resource: CommunicationRequest) : + CPGRequestResource(resource, StatusCodeMapperImpl()) { + override fun setIntent(intent: Intent) { + if (resource.hasExtension("http://hl7.org/fhir/StructureDefinition/request-intent")) { + resource + .getExtensionByUrl("http://hl7.org/fhir/StructureDefinition/request-intent") + .setValue(StringType(intent.code)) + } else { + resource.addExtension( + "http://hl7.org/fhir/StructureDefinition/request-intent", + StringType(intent.code), + ) + } + } + + override fun getIntent() = + resource.getExtensionByUrl("http://hl7.org/fhir/StructureDefinition/request-intent")?.let { + Intent.of(it.value?.primitiveValue()) + } + ?: Intent.of(null) + + override fun setStatus(status: Status, reason: String?) { + resource.status = + CommunicationRequest.CommunicationRequestStatus.fromCode(mapper.mapStatusToCode(status)) + resource.statusReason = reason?.let { CodeableConcept(Coding().setCode(it)) } + } + + override fun getStatusCode() = resource.status?.toCode() + + override fun setBasedOn(reference: Reference) { + resource.addBasedOn(reference) + } + + override fun getBasedOn() = resource.basedOn.lastOrNull() + + override fun copy() = CPGCommunicationRequest(resource.copy()) +} diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/CPGMedicationRequest.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/CPGMedicationRequest.kt new file mode 100644 index 0000000000..4e78d23f7f --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/CPGMedicationRequest.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity.resource.request + +import com.google.android.fhir.workflow.activity.resource.request.Status.REVOKED +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.MedicationRequest +import org.hl7.fhir.r4.model.Reference + +class CPGMedicationRequest(override val resource: MedicationRequest) : + CPGRequestResource(resource, MedicationRequestStatusMapper) { + override fun setIntent(intent: Intent) { + resource.intent = MedicationRequest.MedicationRequestIntent.fromCode(intent.code) + } + + override fun getIntent() = Intent.of(resource.intent?.toCode()) + + override fun setStatus(status: Status, reason: String?) { + resource.status = + MedicationRequest.MedicationRequestStatus.fromCode(mapper.mapStatusToCode(status)) + resource.statusReason = reason?.let { CodeableConcept(Coding().setCode(it)) } + } + + override fun getStatusCode() = resource.status?.toCode() + + override fun setBasedOn(reference: Reference) { + resource.addBasedOn(reference) + } + + override fun getBasedOn() = resource.basedOn.lastOrNull() + + override fun copy() = CPGMedicationRequest(resource.copy()) + + private object MedicationRequestStatusMapper : StatusCodeMapperImpl() { + override fun mapCodeToStatus(code: String?): Status { + return when (code) { + "stopped" -> REVOKED + else -> super.mapCodeToStatus(code) + } + } + + override fun mapStatusToCode(status: Status): String? { + return when (status) { + REVOKED -> "stopped" + else -> super.mapStatusToCode(status) + } + } + } +} diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/CPGRequestResource.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/CPGRequestResource.kt new file mode 100644 index 0000000000..34a959fa2c --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/CPGRequestResource.kt @@ -0,0 +1,194 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity.resource.request + +import com.google.android.fhir.logicalId +import com.google.android.fhir.workflow.activity.resource.request.CPGRequestResource.Companion.of +import com.google.android.fhir.workflow.activity.resource.request.Intent.ORDER +import com.google.android.fhir.workflow.activity.resource.request.Intent.OTHER +import com.google.android.fhir.workflow.activity.resource.request.Intent.PLAN +import com.google.android.fhir.workflow.activity.resource.request.Intent.PROPOSAL +import org.hl7.fhir.r4.model.CommunicationRequest +import org.hl7.fhir.r4.model.IdType +import org.hl7.fhir.r4.model.MedicationRequest +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType +import org.hl7.fhir.r4.model.ServiceRequest +import org.hl7.fhir.r4.model.Task + +/** + * This abstracts the + * [CPG Request Resources](https://build.fhir.org/ig/HL7/cqf-recommendations/profiles.html#activity-profiles) + * used in various activities. The various subclasses of [CPGRequestResource] act as a wrapper + * around the resource they are derived from and helps with the abstracted properties defined for + * each [CPGRequestResource]. e.g. [CPGCommunicationRequest] is a wrapper around the + * [CommunicationRequest] and helps with its [Intent], [Status] and basedOn [Reference]s. + * + * Any direct update to the [resource] can be done by using [update] api. + * + * The application users may use appropriate [of] static factories to create the required + * [CPGRequestResource]s. + * + * **NOTE** + * + * The [resource] must contain appropriate [Resource.meta.profile] for the [of] factories to create + * appropriate [CPGRequestResource]s. e.g. Both [CPGMedicationRequest] and [CPGImmunizationRequest] + * are derived from [MedicationRequest]. So the [MedicationRequest.meta.profile] is required to + * create the appropriate [CPGRequestResource]. + */ +sealed class CPGRequestResource( + internal open val resource: R, + internal val mapper: StatusCodeMapper, +) where R : Resource { + + val resourceType: ResourceType + get() = resource.resourceType + + val logicalId: String + get() = resource.logicalId + + internal abstract fun setIntent(intent: Intent) + + internal abstract fun getIntent(): Intent + + abstract fun setStatus(status: Status, reason: String? = null) + + fun getStatus(): Status = mapper.mapCodeToStatus(getStatusCode()) + + abstract fun getStatusCode(): String? + + abstract fun setBasedOn(reference: Reference) + + abstract fun getBasedOn(): Reference? + + internal abstract fun copy(): CPGRequestResource + + internal fun copy(id: String, status: Status, intent: Intent): CPGRequestResource { + val parent: CPGRequestResource = this + return copy().apply { + resource.idElement = IdType.of(resource).setValue(id) + setStatus(status) + setIntent(intent) + setBasedOn(Reference("${parent.resource.resourceType}/${parent.resource.logicalId}")) + } + } + + fun asReference() = Reference("${resource.resourceType}/${resource.logicalId}") + + companion object { + + fun of(klass: CPGRequestResource<*>, resource: R): CPGRequestResource { + return when (klass::class.java) { + CPGCommunicationRequest::class.java -> + CPGCommunicationRequest(resource as CommunicationRequest) + CPGMedicationRequest::class.java -> CPGMedicationRequest(resource as MedicationRequest) + else -> { + throw IllegalArgumentException("Unknown CPG Request type ${resource::class}.") + } + } + as CPGRequestResource + } + + // fun of(resource: Task) = CPGTaskRequest(resource) + + fun of(resource: MedicationRequest) = + if ( + resource.meta.hasProfile( + "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-medicationrequest", + ) + ) { + CPGMedicationRequest(resource) + } else { + throw IllegalArgumentException("Unknown cpg profile") + } + + fun of(resource: CommunicationRequest) = CPGCommunicationRequest(resource) + + /** + * the resource.meta.profile describes the activity and should be used to create particular cpg + * request + */ + fun of(resource: R): CPGRequestResource { + return when (resource) { + is Task -> of(resource) + is MedicationRequest -> of(resource) + is ServiceRequest -> of(resource) + is CommunicationRequest -> of(resource) + else -> { + throw IllegalArgumentException("Unknown CPG Request type ${resource::class}.") + } + } + as CPGRequestResource + } + } +} + +/** + * [PROPOSAL], [PLAN] and [ORDER] are the only intents we are interested in. All the other Request + * Intent values are represented by [OTHER]. + * + * See [codesystem-request-intent](https://www.hl7.org/FHIR/codesystem-request-intent.html) for the + * list of intents. + */ +internal sealed class Intent(val code: String?) { + data object PROPOSAL : Intent("proposal") + + data object PLAN : Intent("plan") + + data object ORDER : Intent("order") + + class OTHER(code: String?) : Intent(code) + + override fun toString(): String { + return code ?: "null" + } + + companion object { + fun of(code: String?): Intent { + return when (code) { + "proposal" -> PROPOSAL + "plan" -> PLAN + "order" -> ORDER + else -> OTHER(code) + } + } + } +} + +/** + * For the activity flow, we are interested in a few status and they are be represented as + * individual values here. Everything else is represented by [OTHER]. + * + * See [codesystem-resource-status](https://build.fhir.org/codesystem-resource-status.html) for list + * of the status. + */ +sealed interface Status { + data object DRAFT : Status + + data object ACTIVE : Status + + data object ONHOLD : Status + + data object REVOKED : Status + + data object COMPLETED : Status + + data object ENTEREDINERROR : Status + + class OTHER(val code: String?) : Status +} diff --git a/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/StatusCodeMapper.kt b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/StatusCodeMapper.kt new file mode 100644 index 0000000000..1657eadab7 --- /dev/null +++ b/workflow/src/main/java/com/google/android/fhir/workflow/activity/resource/request/StatusCodeMapper.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity.resource.request + +import com.google.android.fhir.workflow.activity.resource.request.Status.ACTIVE +import com.google.android.fhir.workflow.activity.resource.request.Status.COMPLETED +import com.google.android.fhir.workflow.activity.resource.request.Status.DRAFT +import com.google.android.fhir.workflow.activity.resource.request.Status.ENTEREDINERROR +import com.google.android.fhir.workflow.activity.resource.request.Status.ONHOLD +import com.google.android.fhir.workflow.activity.resource.request.Status.OTHER +import com.google.android.fhir.workflow.activity.resource.request.Status.REVOKED + +/** + * Since request resources may have different code for same status, each [CPGRequestResource] should + * provide its own mapper. See + * [columns next to status](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#activity-lifecycle---request-phases-proposal-plan-order) + */ +internal interface StatusCodeMapper { + + fun mapCodeToStatus(code: String?): Status + + fun mapStatusToCode(status: Status): String? +} + +/** A base implementation where the status and code map each other. */ +internal open class StatusCodeMapperImpl : StatusCodeMapper { + override fun mapCodeToStatus(code: String?): Status { + return when (code) { + "draft" -> DRAFT + "active" -> ACTIVE + "on-hold" -> ONHOLD + "revoked" -> REVOKED + "completed" -> COMPLETED + "entered-in-error" -> ENTEREDINERROR + else -> OTHER(code) + } + } + + override fun mapStatusToCode(status: Status): String? { + return when (status) { + DRAFT -> "draft" + ACTIVE -> "active" + ONHOLD -> "on-hold" + REVOKED -> "revoked" + COMPLETED -> "completed" + ENTEREDINERROR -> "entered-in-error" + is OTHER -> status.code + } + } +} diff --git a/workflow/src/test/java/com/google/android/fhir/workflow/activity/ActivityFlowTest.kt b/workflow/src/test/java/com/google/android/fhir/workflow/activity/ActivityFlowTest.kt new file mode 100644 index 0000000000..6356b26b78 --- /dev/null +++ b/workflow/src/test/java/com/google/android/fhir/workflow/activity/ActivityFlowTest.kt @@ -0,0 +1,645 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.workflow.activity + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import ca.uhn.fhir.context.FhirContext +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.FhirEngineProvider +import com.google.android.fhir.workflow.activity.phase.Phase +import com.google.android.fhir.workflow.activity.phase.event.PerformPhase +import com.google.android.fhir.workflow.activity.phase.request.OrderPhase +import com.google.android.fhir.workflow.activity.phase.request.PlanPhase +import com.google.android.fhir.workflow.activity.phase.request.ProposalPhase +import com.google.android.fhir.workflow.activity.resource.event.CPGCommunicationEvent +import com.google.android.fhir.workflow.activity.resource.event.EventStatus +import com.google.android.fhir.workflow.activity.resource.request.CPGCommunicationRequest +import com.google.android.fhir.workflow.activity.resource.request.CPGRequestResource +import com.google.android.fhir.workflow.activity.resource.request.Intent +import com.google.android.fhir.workflow.activity.resource.request.Status +import com.google.android.fhir.workflow.repositories.FhirEngineRepository +import com.google.android.fhir.workflow.runBlockingOnWorkerThread +import com.google.android.fhir.workflow.testing.FhirEngineProviderTestRule +import com.google.common.truth.Truth.assertThat +import kotlin.test.fail +import org.hl7.fhir.r4.model.CommunicationRequest +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.StringType +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@Suppress( + "UnstableApiUsage", /*Repository is marked @Beta */ + "UNCHECKED_CAST", /*Cast type erased ActivityFlow to a concrete type ActivityFlow*/ +) +@RunWith(RobolectricTestRunner::class) +class ActivityFlowTest { + + @get:Rule val fhirEngineProviderRule = FhirEngineProviderTestRule() + private lateinit var fhirEngine: FhirEngine + + @Before + fun setupTest() { + val context: Context = ApplicationProvider.getApplicationContext() + fhirEngine = FhirEngineProvider.getInstance(context) + } + + @Test + fun `preparePlan should succeed when in proposal phase`(): Unit = runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile( + "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest", + ) + + addPayload().apply { content = StringType("Proposal") } + }, + ) + .apply { setIntent(Intent.PROPOSAL) } + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + + val proposalPhase = ActivityFlow.of(repository, cpgCommunicationRequest) + + assertThat(proposalPhase.getCurrentPhase()).isInstanceOf(ProposalPhase::class.java) + assertThat(proposalPhase.preparePlan().isSuccess).isTrue() + } + + @Test + fun `preparePlan should fail when in in plan phase`(): Unit = runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile("http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest") + + addPayload().apply { content = StringType("Proposal") } + }, + ) + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + val cpgCommunicationPlanRequest = + cpgCommunicationRequest.copy( + id = "com-req-01=plan", + status = Status.ACTIVE, + intent = Intent.PLAN, + ) as CPGCommunicationRequest + + val planPhase = ActivityFlow.of(repository, cpgCommunicationPlanRequest) + + assertThat(planPhase.getCurrentPhase()).isInstanceOf(PlanPhase::class.java) + assertThat(planPhase.preparePlan().isFailure).isTrue() + } + + @Test + fun `preparePlan should fail when in in order phase`(): Unit = runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile("http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest") + + addPayload().apply { content = StringType("Proposal") } + }, + ) + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + val cpgCommunicationOrderRequest = + cpgCommunicationRequest.copy( + id = "com-req-01=plan", + status = Status.ACTIVE, + intent = Intent.ORDER, + ) as CPGCommunicationRequest + + val orderPhase = ActivityFlow.of(repository, cpgCommunicationOrderRequest) + + assertThat(orderPhase.getCurrentPhase()).isInstanceOf(OrderPhase::class.java) + assertThat(orderPhase.preparePlan().isFailure).isTrue() + } + + @Test + fun `preparePlan should fail when in in perform phase`(): Unit = runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile("http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest") + + addPayload().apply { content = StringType("Proposal") } + }, + ) + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + val cpgCommunicationEvent = CPGCommunicationEvent.from(cpgCommunicationRequest) + + val performPhase = ActivityFlow.of(repository, cpgCommunicationEvent) + + assertThat(performPhase.getCurrentPhase()).isInstanceOf(PerformPhase::class.java) + assertThat(performPhase.preparePlan().isFailure).isTrue() + } + + @Test + fun `prepareOrder should succeed when in proposal phase`(): Unit = runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile( + "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest", + ) + + addPayload().apply { content = StringType("Proposal") } + }, + ) + .apply { setIntent(Intent.PROPOSAL) } + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + + val proposalPhase = ActivityFlow.of(repository, cpgCommunicationRequest) + + assertThat(proposalPhase.getCurrentPhase()).isInstanceOf(ProposalPhase::class.java) + assertThat(proposalPhase.prepareOrder().isSuccess).isTrue() + } + + @Test + fun `prepareOrder should succeed when in plan phase`(): Unit = runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile("http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest") + + addPayload().apply { content = StringType("Proposal") } + }, + ) + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + val cpgCommunicationPlanRequest = + cpgCommunicationRequest.copy( + id = "com-req-01=plan", + status = Status.ACTIVE, + intent = Intent.PLAN, + ) as CPGCommunicationRequest + + val planPhase = ActivityFlow.of(repository, cpgCommunicationPlanRequest) + + assertThat(planPhase.getCurrentPhase()).isInstanceOf(PlanPhase::class.java) + assertThat(planPhase.prepareOrder().isSuccess).isTrue() + } + + @Test + fun `prepareOrder should fail when in in order phase`(): Unit = runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile("http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest") + + addPayload().apply { content = StringType("Proposal") } + }, + ) + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + val cpgCommunicationOrderRequest = + cpgCommunicationRequest.copy( + id = "com-req-01=plan", + status = Status.ACTIVE, + intent = Intent.ORDER, + ) as CPGCommunicationRequest + + val orderPhase = ActivityFlow.of(repository, cpgCommunicationOrderRequest) + + assertThat(orderPhase.getCurrentPhase()).isInstanceOf(OrderPhase::class.java) + assertThat(orderPhase.prepareOrder().isFailure).isTrue() + } + + @Test + fun `prepareOrder should fail when in in perform phase`(): Unit = runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile("http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest") + + addPayload().apply { content = StringType("Proposal") } + }, + ) + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + val cpgCommunicationEvent = CPGCommunicationEvent.from(cpgCommunicationRequest) + + val performPhase = ActivityFlow.of(repository, cpgCommunicationEvent) + + assertThat(performPhase.getCurrentPhase()).isInstanceOf(PerformPhase::class.java) + assertThat(performPhase.prepareOrder().isFailure).isTrue() + } + + @Test + fun `preparePerform should succeed when in proposal phase`(): Unit = runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile( + "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest", + ) + + addPayload().apply { content = StringType("Proposal") } + }, + ) + .apply { setIntent(Intent.PROPOSAL) } + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + + val proposalPhase = ActivityFlow.of(repository, cpgCommunicationRequest) + + assertThat(proposalPhase.getCurrentPhase()).isInstanceOf(ProposalPhase::class.java) + assertThat(proposalPhase.preparePerform(CPGCommunicationEvent::class.java).isSuccess).isTrue() + } + + @Test + fun `preparePerform should succeed when in plan phase`(): Unit = runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile("http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest") + + addPayload().apply { content = StringType("Proposal") } + }, + ) + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + val cpgCommunicationPlanRequest = + cpgCommunicationRequest.copy( + id = "com-req-01=plan", + status = Status.ACTIVE, + intent = Intent.PLAN, + ) as CPGCommunicationRequest + + val planPhase = ActivityFlow.of(repository, cpgCommunicationPlanRequest) + + assertThat(planPhase.getCurrentPhase()).isInstanceOf(PlanPhase::class.java) + assertThat(planPhase.preparePerform(CPGCommunicationEvent::class.java).isSuccess).isTrue() + } + + @Test + fun `preparePerform should succeed when in order phase`(): Unit = runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile("http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest") + + addPayload().apply { content = StringType("Proposal") } + }, + ) + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + val cpgCommunicationOrderRequest = + cpgCommunicationRequest.copy( + id = "com-req-01=plan", + status = Status.ACTIVE, + intent = Intent.ORDER, + ) as CPGCommunicationRequest + + val orderPhase = ActivityFlow.of(repository, cpgCommunicationOrderRequest) + + assertThat(orderPhase.getCurrentPhase()).isInstanceOf(OrderPhase::class.java) + assertThat(orderPhase.preparePerform(CPGCommunicationEvent::class.java).isSuccess).isTrue() + } + + @Test + fun `preparePerform should fail when in in perform phase`(): Unit = runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile("http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest") + + addPayload().apply { content = StringType("Proposal") } + }, + ) + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + val cpgCommunicationEvent = CPGCommunicationEvent.from(cpgCommunicationRequest) + + val performPhase = ActivityFlow.of(repository, cpgCommunicationEvent) + + assertThat(performPhase.getCurrentPhase()).isInstanceOf(PerformPhase::class.java) + assertThat(performPhase.preparePerform(CPGCommunicationEvent::class.java).isFailure).isTrue() + } + + @Test + fun `getCurrentPhase should return the current phase of the flow`() = runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile( + "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest", + ) + + addPayload().apply { content = StringType("Proposal") } + }, + ) + .apply { setIntent(Intent.PROPOSAL) } + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + + val flow = ActivityFlow.of(repository, cpgCommunicationRequest) + + assertThat(flow.getCurrentPhase()).isInstanceOf(ProposalPhase::class.java) + + flow + .preparePlan() + .onSuccess { + it.setStatus(Status.ACTIVE) + flow.initiatePlan(it) + } + .onFailure { fail("Unexpected", it) } + + assertThat(flow.getCurrentPhase()).isInstanceOf(PlanPhase::class.java) + + flow + .prepareOrder() + .onSuccess { + it.setStatus(Status.ACTIVE) + flow.initiateOrder(it) + } + .onFailure { fail("Unexpected", it) } + + assertThat(flow.getCurrentPhase()).isInstanceOf(OrderPhase::class.java) + } + + @Test + fun `initiatePlan should move the flow to plan phase when correct prepared plan is provided`() = + runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile( + "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest", + ) + + addPayload().apply { content = StringType("Proposal") } + }, + ) + .apply { setIntent(Intent.PROPOSAL) } + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + + val flow = ActivityFlow.of(repository, cpgCommunicationRequest) + + assertThat(flow.getCurrentPhase().getPhaseName()).isEqualTo(Phase.PhaseName.PROPOSAL) + + val preparePlan = flow.preparePlan() + assertThat(preparePlan.isSuccess).isTrue() + + val initiatedPlan = + preparePlan.getOrThrow().let { + it.setStatus(Status.ACTIVE) + flow.initiatePlan(it) + } + + assertThat(initiatedPlan.isSuccess).isTrue() + assertThat(initiatedPlan.getOrThrow().getPhaseName()).isEqualTo(Phase.PhaseName.PLAN) + } + + @Test + fun `initiatePlan should fail when provided plan when corrupted prepared plan is provided`() = + runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile( + "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest", + ) + + addPayload().apply { content = StringType("Proposal") } + }, + ) + .apply { setIntent(Intent.PROPOSAL) } + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + + val flow = ActivityFlow.of(repository, cpgCommunicationRequest) + + assertThat(flow.getCurrentPhase().getPhaseName()).isEqualTo(Phase.PhaseName.PROPOSAL) + + val preparePlan = flow.preparePlan() + assertThat(preparePlan.isSuccess).isTrue() + + val preparedPlanResource = preparePlan.getOrThrow() + preparedPlanResource.let { + it.setStatus(Status.ACTIVE) + it.resource.basedOn.last().apply { this.reference = "" } + } + val initiatedPlan = preparePlan.getOrThrow().let { flow.initiatePlan(it) } + + assertThat(initiatedPlan.isFailure).isTrue() + // check that the flow is still in old phase (proposal). + assertThat(flow.getCurrentPhase().getPhaseName()).isEqualTo(Phase.PhaseName.PROPOSAL) + } + + @Test + fun `initiateOrder should move the flow to order phase when correct prepared order is provided`() = + runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile( + "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest", + ) + + addPayload().apply { content = StringType("Proposal") } + }, + ) + .apply { setIntent(Intent.PROPOSAL) } + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + + val flow = ActivityFlow.of(repository, cpgCommunicationRequest) + + assertThat(flow.getCurrentPhase().getPhaseName()).isEqualTo(Phase.PhaseName.PROPOSAL) + + val prepareOrder = flow.prepareOrder() + assertThat(prepareOrder.isSuccess).isTrue() + + val initiatedOrder = + prepareOrder.getOrThrow().let { + it.setStatus(Status.ACTIVE) + flow.initiateOrder(it) + } + + assertThat(initiatedOrder.isSuccess).isTrue() + assertThat(initiatedOrder.getOrThrow().getPhaseName()).isEqualTo(Phase.PhaseName.ORDER) + } + + @Test + fun `initiateOrder should fail when provided order when corrupted prepared order is provided`() = + runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile( + "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest", + ) + + addPayload().apply { content = StringType("Proposal") } + }, + ) + .apply { setIntent(Intent.PROPOSAL) } + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + + val flow = ActivityFlow.of(repository, cpgCommunicationRequest) + + assertThat(flow.getCurrentPhase().getPhaseName()).isEqualTo(Phase.PhaseName.PROPOSAL) + + val prepareOrder = flow.prepareOrder() + assertThat(prepareOrder.isSuccess).isTrue() + + val preparedPlanResource = prepareOrder.getOrThrow() + preparedPlanResource.let { + it.setStatus(Status.ACTIVE) + it.resource.basedOn.last().apply { this.reference = "" } + } + val initiatedOrder = prepareOrder.getOrThrow().let { flow.initiateOrder(it) } + + assertThat(initiatedOrder.isFailure).isTrue() + // check that the flow is still in old phase (proposal). + assertThat(flow.getCurrentPhase().getPhaseName()).isEqualTo(Phase.PhaseName.PROPOSAL) + } + + @Test + fun `initiatePerform should move the flow to perform phase when correct prepared event is provided`() = + runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile( + "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest", + ) + + addPayload().apply { content = StringType("Proposal") } + }, + ) + .apply { setIntent(Intent.PROPOSAL) } + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + + val flow = ActivityFlow.of(repository, cpgCommunicationRequest) + + assertThat(flow.getCurrentPhase().getPhaseName()).isEqualTo(Phase.PhaseName.PROPOSAL) + + val preparePerform = flow.preparePerform(CPGCommunicationEvent::class.java) + assertThat(preparePerform.isSuccess).isTrue() + + val preparedEvent = preparePerform.getOrThrow() + preparedEvent.let { it.setStatus(EventStatus.INPROGRESS) } + val initiatedPerform = preparePerform.getOrThrow().let { flow.initiatePerform(it) } + assertThat(initiatedPerform.isSuccess).isTrue() + assertThat(initiatedPerform.getOrThrow().getPhaseName()).isEqualTo(Phase.PhaseName.PERFORM) + } + + @Test + fun `initiatePerform should fail when corrupted prepared event is provided`() = + runBlockingOnWorkerThread { + val cpgCommunicationRequest = + CPGRequestResource.of( + CommunicationRequest().apply { + id = "com-req-01" + status = CommunicationRequest.CommunicationRequestStatus.ACTIVE + subject = Reference("Patient/pat-01") + meta.addProfile( + "http://hl7.org/fhir/uv/cpg/StructureDefinition/cpg-communicationrequest", + ) + + addPayload().apply { content = StringType("Proposal") } + }, + ) + .apply { setIntent(Intent.PROPOSAL) } + val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) + repository.create(cpgCommunicationRequest.resource) + + val flow = ActivityFlow.of(repository, cpgCommunicationRequest) + + assertThat(flow.getCurrentPhase().getPhaseName()).isEqualTo(Phase.PhaseName.PROPOSAL) + + val preparePerform = flow.preparePerform(CPGCommunicationEvent::class.java) + assertThat(preparePerform.isSuccess).isTrue() + + val preparedEvent = preparePerform.getOrThrow() + preparedEvent.let { + it.setStatus(EventStatus.INPROGRESS) + it.resource.basedOn.last().apply { this.reference = "" } + } + val initiatedPerform = preparePerform.getOrThrow().let { flow.initiatePerform(it) } + + assertThat(initiatedPerform.isFailure).isTrue() + // check that the flow is still in old phase (proposal). + assertThat(flow.getCurrentPhase().getPhaseName()).isEqualTo(Phase.PhaseName.PROPOSAL) + } +} From 424ab83b72b85847dfaf5343e4c8191de242f13c Mon Sep 17 00:00:00 2001 From: Martin Ndegwa Date: Mon, 7 Oct 2024 09:46:21 +0300 Subject: [PATCH 06/17] Implement Parallelized map and optimize Database search API (#2669) * Implement Parallelized Map - Optimize Database search API * Search API perfomance DB optimization - Default Dispatcher * Clean up * Clean up PR * Remove Concurrency Unit Test --- .../main/java/com/google/android/fhir/Util.kt | 10 +++++++++ .../android/fhir/db/impl/DatabaseImpl.kt | 22 ++++++++++++++----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/engine/src/main/java/com/google/android/fhir/Util.kt b/engine/src/main/java/com/google/android/fhir/Util.kt index 5ad65a52e9..83913fb40b 100644 --- a/engine/src/main/java/com/google/android/fhir/Util.kt +++ b/engine/src/main/java/com/google/android/fhir/Util.kt @@ -26,6 +26,10 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Date import java.util.Locale +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import org.hl7.fhir.r4.model.OperationOutcome import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -69,6 +73,12 @@ internal fun Resource.isUploadSuccess(): Boolean { outcome.issue.all { it.severity.equals(OperationOutcome.IssueSeverity.INFORMATION) } } +/** Implementation of a parallelized map */ +suspend fun Iterable.pmap(dispatcher: CoroutineDispatcher, f: suspend (A) -> B): List = + coroutineScope { + map { async(dispatcher) { f(it) } }.awaitAll() + } + internal class OffsetDateTimeTypeAdapter : TypeAdapter() { override fun write(out: JsonWriter, value: OffsetDateTime) { out.value(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value)) diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt b/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt index 408c3a7054..40de8d320b 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt @@ -21,6 +21,7 @@ import androidx.annotation.VisibleForTesting import androidx.room.Room import androidx.room.withTransaction import androidx.sqlite.db.SimpleSQLiteQuery +import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.parser.IParser import ca.uhn.fhir.util.FhirTerser import com.google.android.fhir.DatabaseErrorStrategy @@ -37,11 +38,13 @@ import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.android.fhir.db.impl.entities.ResourceEntity import com.google.android.fhir.index.ResourceIndexer import com.google.android.fhir.logicalId +import com.google.android.fhir.pmap import com.google.android.fhir.search.SearchQuery import com.google.android.fhir.toLocalChange import com.google.android.fhir.updateMeta import java.time.Instant import java.util.UUID +import kotlinx.coroutines.Dispatchers import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -227,8 +230,13 @@ internal class DatabaseImpl( query: SearchQuery, ): List> { return db.withTransaction { - resourceDao.getResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())).map { - ResourceWithUUID(it.uuid, iParser.parseResource(it.serializedResource) as R) + resourceDao.getResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())).pmap( + Dispatchers.Default, + ) { + ResourceWithUUID( + it.uuid, + FhirContext.forR4Cached().newJsonParser().parseResource(it.serializedResource) as R, + ) } } } @@ -239,11 +247,12 @@ internal class DatabaseImpl( return db.withTransaction { resourceDao .getForwardReferencedResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())) - .map { + .pmap(Dispatchers.Default) { ForwardIncludeSearchResult( it.matchingIndex, it.baseResourceUUID, - iParser.parseResource(it.serializedResource) as Resource, + FhirContext.forR4Cached().newJsonParser().parseResource(it.serializedResource) + as Resource, ) } } @@ -255,11 +264,12 @@ internal class DatabaseImpl( return db.withTransaction { resourceDao .getReverseReferencedResources(SimpleSQLiteQuery(query.query, query.args.toTypedArray())) - .map { + .pmap(Dispatchers.Default) { ReverseIncludeSearchResult( it.matchingIndex, it.baseResourceTypeAndId, - iParser.parseResource(it.serializedResource) as Resource, + FhirContext.forR4Cached().newJsonParser().parseResource(it.serializedResource) + as Resource, ) } } From 92da65c313ed992e8ed3de24675ff9600a8bc46a Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Tue, 8 Oct 2024 18:50:26 +0100 Subject: [PATCH 07/17] Update internal dependency on knowledge to beta01 (#2690) --- buildSrc/src/main/kotlin/Dependencies.kt | 11 ----------- datacapture/build.gradle.kts | 4 ++-- demo/build.gradle.kts | 2 +- document/build.gradle.kts | 2 +- engine/build.gradle.kts | 2 +- gradle/libs.versions.toml | 6 ++++++ workflow/benchmark/build.gradle.kts | 6 +++--- workflow/build.gradle.kts | 8 +++++--- 8 files changed, 19 insertions(+), 22 deletions(-) diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 83590d9f68..594599d64e 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -87,14 +87,6 @@ object Dependencies { const val playServicesLocation = "com.google.android.gms:play-services-location:${Versions.playServicesLocation}" - const val androidFhirGroup = "com.google.android.fhir" - const val androidFhirEngineModule = "engine" - const val androidFhirKnowledgeModule = "knowledge" - const val androidFhirCommon = "$androidFhirGroup:common:${Versions.androidFhirCommon}" - const val androidFhirEngine = - "$androidFhirGroup:$androidFhirEngineModule:${Versions.androidFhirEngine}" - const val androidFhirKnowledge = "$androidFhirGroup:knowledge:${Versions.androidFhirKnowledge}" - const val apacheCommonsCompress = "org.apache.commons:commons-compress:${Versions.apacheCommonsCompress}" @@ -131,9 +123,6 @@ object Dependencies { const val xmlUnit = "org.xmlunit:xmlunit-core:${Versions.xmlUnit}" object Versions { - const val androidFhirCommon = "0.1.0-alpha05" - const val androidFhirEngine = "0.1.0-beta05" - const val androidFhirKnowledge = "0.1.0-alpha03" const val apacheCommonsCompress = "1.21" const val desugarJdkLibs = "2.0.3" const val caffeine = "2.9.1" diff --git a/datacapture/build.gradle.kts b/datacapture/build.gradle.kts index 8726fc504f..34d81ab04c 100644 --- a/datacapture/build.gradle.kts +++ b/datacapture/build.gradle.kts @@ -90,9 +90,9 @@ dependencies { exclude(module = "commons-logging") exclude(module = "httpclient") } - implementation(Dependencies.androidFhirCommon) implementation(Dependencies.material) implementation(Dependencies.timber) + implementation(libs.android.fhir.common) implementation(libs.androidx.appcompat) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core) @@ -106,7 +106,7 @@ dependencies { testImplementation(Dependencies.mockitoKotlin) testImplementation(Dependencies.robolectric) testImplementation(project(":knowledge")) { - exclude(group = Dependencies.androidFhirGroup, module = Dependencies.androidFhirEngineModule) + exclude(group = "com.google.android.fhir", module = "engine") } testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.fragment.testing) diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index d5440eba6e..7d0fb5c13c 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -61,7 +61,7 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.core) implementation(project(":datacapture")) { - exclude(group = Dependencies.androidFhirGroup, module = Dependencies.androidFhirEngineModule) + exclude(group = "com.google.android.fhir", module = "engine") } implementation(project(":engine")) diff --git a/document/build.gradle.kts b/document/build.gradle.kts index eb30422a4c..4190bcd57f 100644 --- a/document/build.gradle.kts +++ b/document/build.gradle.kts @@ -39,13 +39,13 @@ dependencies { coreLibraryDesugaring(Dependencies.desugarJdkLibs) implementation(Dependencies.material) - implementation(Dependencies.androidFhirEngine) implementation(Dependencies.Retrofit.coreRetrofit) implementation(Dependencies.Retrofit.gsonConverter) implementation(Dependencies.httpInterceptor) implementation(Dependencies.zxing) implementation(Dependencies.nimbus) implementation(Dependencies.timber) + implementation(libs.android.fhir.engine) implementation(libs.androidx.appcompat) implementation(libs.androidx.core) diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts index d18f5f60ed..488705076a 100644 --- a/engine/build.gradle.kts +++ b/engine/build.gradle.kts @@ -113,7 +113,6 @@ dependencies { exclude(module = "httpclient") } implementation(Dependencies.Retrofit.coreRetrofit) - implementation(Dependencies.androidFhirCommon) implementation(Dependencies.guava) implementation(Dependencies.httpInterceptor) implementation(Dependencies.jsonToolsPatch) @@ -121,6 +120,7 @@ dependencies { implementation(Dependencies.timber) implementation(Dependencies.woodstox) implementation(Dependencies.xerces) + implementation(libs.android.fhir.common) implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.lifecycle.livedata) implementation(libs.androidx.room.room) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9a1d4ad458..b5edb4df63 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,9 @@ # see https://docs.gradle.org/current/userguide/platforms.html [versions] +android-fhir-common = "0.1.0-alpha05" +android-fhir-engine = "0.1.0-beta05" +android-fhir-knowledge = "0.1.0-beta01" androidx-acivity = "1.7.2" androidx-appcompat = "1.6.1" androidx-arch-core = "2.2.0" @@ -30,6 +33,9 @@ opencds-cqf-fhir = "3.8.0" truth = "1.1.5" [libraries] +android-fhir-common = { module = "com.google.android.fhir:common", version.ref = "android-fhir-common" } +android-fhir-engine = { module = "com.google.android.fhir:engine", version.ref = "android-fhir-engine" } +android-fhir-knowledge = { module = "com.google.android.fhir:knowledge", version.ref = "android-fhir-knowledge" } androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-acivity" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-arch-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "androidx-arch-core" } diff --git a/workflow/benchmark/build.gradle.kts b/workflow/benchmark/build.gradle.kts index 7d1d5a2d26..0fd4f72e71 100644 --- a/workflow/benchmark/build.gradle.kts +++ b/workflow/benchmark/build.gradle.kts @@ -70,11 +70,11 @@ dependencies { androidTestImplementation(libs.truth) androidTestImplementation(project(":engine")) androidTestImplementation(project(":knowledge")) { - exclude(group = Dependencies.androidFhirGroup, module = Dependencies.androidFhirEngineModule) + exclude(group = "com.google.android.fhir", module = "engine") } androidTestImplementation(project(":workflow")) { - exclude(group = Dependencies.androidFhirGroup, module = Dependencies.androidFhirEngineModule) - exclude(group = Dependencies.androidFhirGroup, module = Dependencies.androidFhirKnowledgeModule) + exclude(group = "com.google.android.fhir", module = "engine") + exclude(group = "com.google.android.fhir", module = "knowledge") } androidTestImplementation(project(":workflow-testing")) diff --git a/workflow/build.gradle.kts b/workflow/build.gradle.kts index 2f9c2688e3..cfe08f27e4 100644 --- a/workflow/build.gradle.kts +++ b/workflow/build.gradle.kts @@ -1,5 +1,6 @@ import Dependencies.removeIncompatibleDependencies import java.net.URL +import org.gradle.internal.impldep.org.junit.experimental.categories.Categories.CategoryFilter.exclude plugins { id(Plugins.BuildPlugins.androidLib) @@ -95,10 +96,10 @@ dependencies { api(Dependencies.HapiFhir.guavaCaching) implementation(Dependencies.HapiFhir.guavaCaching) - implementation(Dependencies.androidFhirEngine) { exclude(module = "truth") } - implementation(Dependencies.androidFhirKnowledge) implementation(Dependencies.timber) implementation(Dependencies.xerces) + implementation(libs.android.fhir.engine) { exclude(module = "truth") } + implementation(libs.android.fhir.knowledge) implementation(libs.androidx.core) implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.android) @@ -123,7 +124,8 @@ dependencies { resolutionStrategy.dependencySubstitution { // To test the workflow library against the latest Knowledge Manager APIs, substitute the // dependency on the released Knowledge Manager library with the current build. - substitute(module(Dependencies.androidFhirKnowledge)).using(project(":knowledge")) + substitute(module("com.google.android.fhir:knowledge:0.1.0-beta01")) + .using(project(":knowledge")) } } } From 62d3f89e9ffb4fa122006f3c3c19e995674bc136 Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Wed, 9 Oct 2024 12:45:53 +0100 Subject: [PATCH 08/17] Update workflow library version to beta01 (#2693) --- buildSrc/src/main/kotlin/Releases.kt | 2 +- docs/use/api.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/Releases.kt b/buildSrc/src/main/kotlin/Releases.kt index 30172a9762..dae9dfcc8b 100644 --- a/buildSrc/src/main/kotlin/Releases.kt +++ b/buildSrc/src/main/kotlin/Releases.kt @@ -60,7 +60,7 @@ object Releases { object Workflow : LibraryArtifact { override val artifactId = "workflow" - override val version = "0.1.0-alpha04" + override val version = "0.1.0-beta01" override val name = "Android FHIR Workflow Library" } diff --git a/docs/use/api.md b/docs/use/api.md index c9c1365ec0..3bd092acf8 100644 --- a/docs/use/api.md +++ b/docs/use/api.md @@ -2,5 +2,5 @@ * [Engine](api/engine/1.0.0/index.html) * [Data Capture](api/data-capture/1.2.0/index.html) -* [Workflow](api/workflow/0.1.0-alpha04/index.html) +* [Workflow](api/workflow/0.1.0-beta01/index.html) * [Knowledge](api/knowledge/0.1.0-beta01/index.html) From 9076f377f8e6fe7507ee2bcbdb624aa90f3d5fbf Mon Sep 17 00:00:00 2001 From: santosh-pingle <86107848+santosh-pingle@users.noreply.github.com> Date: Fri, 11 Oct 2024 17:27:40 +0530 Subject: [PATCH 09/17] Manual sync as separate screen. (#2691) * Manual sync as separate screen. * Remove navigation drawer. * update last sync label. * update label. * spotless apply * address review comments. * Remove commented code. --------- Co-authored-by: Santosh Pingle --- .../android/fhir/demo/AddPatientFragment.kt | 1 - .../android/fhir/demo/EditPatientFragment.kt | 1 - .../google/android/fhir/demo/HomeFragment.kt | 16 +-- .../google/android/fhir/demo/MainActivity.kt | 55 --------- .../fhir/demo/PatientDetailsFragment.kt | 3 +- .../android/fhir/demo/PatientListFragment.kt | 3 +- .../google/android/fhir/demo/SyncFragment.kt | 104 ++++++++++++++++++ ...yViewModel.kt => SyncFragmentViewModel.kt} | 2 +- demo/src/main/res/drawable/ic_home_sync.xml | 15 +++ demo/src/main/res/layout/activity_main.xml | 59 ++++------ demo/src/main/res/layout/fragment_home.xml | 43 ++++++++ demo/src/main/res/layout/sync.xml | 82 ++++++++++++++ .../res/navigation/reference_nav_graph.xml | 11 ++ demo/src/main/res/values/strings.xml | 3 + 14 files changed, 283 insertions(+), 115 deletions(-) create mode 100644 demo/src/main/java/com/google/android/fhir/demo/SyncFragment.kt rename demo/src/main/java/com/google/android/fhir/demo/{MainActivityViewModel.kt => SyncFragmentViewModel.kt} (98%) create mode 100644 demo/src/main/res/drawable/ic_home_sync.xml create mode 100644 demo/src/main/res/layout/sync.xml diff --git a/demo/src/main/java/com/google/android/fhir/demo/AddPatientFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/AddPatientFragment.kt index be15d4cb05..d271e3cbcb 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/AddPatientFragment.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/AddPatientFragment.kt @@ -44,7 +44,6 @@ class AddPatientFragment : Fragment(R.layout.add_patient_fragment) { addQuestionnaireFragment() } observePatientSaveAction() - (activity as MainActivity).setDrawerEnabled(false) /** Use the provided cancel|submit buttons from the sdc library */ childFragmentManager.setFragmentResultListener( diff --git a/demo/src/main/java/com/google/android/fhir/demo/EditPatientFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/EditPatientFragment.kt index 43b0914b1b..8b11187b71 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/EditPatientFragment.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/EditPatientFragment.kt @@ -55,7 +55,6 @@ class EditPatientFragment : Fragment(R.layout.add_patient_fragment) { Toast.makeText(requireContext(), R.string.message_patient_updated, Toast.LENGTH_SHORT).show() NavHostFragment.findNavController(this).navigateUp() } - (activity as MainActivity).setDrawerEnabled(false) /** Use the provided cancel|submit buttons from the sdc library */ childFragmentManager.setFragmentResultListener( diff --git a/demo/src/main/java/com/google/android/fhir/demo/HomeFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/HomeFragment.kt index c6285d685e..32dfa833a6 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/HomeFragment.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/HomeFragment.kt @@ -17,7 +17,6 @@ package com.google.android.fhir.demo import android.os.Bundle -import android.view.MenuItem import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.cardview.widget.CardView @@ -30,10 +29,8 @@ class HomeFragment : Fragment(R.layout.fragment_home) { super.onViewCreated(view, savedInstanceState) (requireActivity() as AppCompatActivity).supportActionBar?.apply { title = resources.getString(R.string.app_name) - setDisplayHomeAsUpEnabled(true) + setDisplayHomeAsUpEnabled(false) } - setHasOptionsMenu(true) - (activity as MainActivity).setDrawerEnabled(true) setOnClicks() } @@ -47,15 +44,8 @@ class HomeFragment : Fragment(R.layout.fragment_home) { requireView().findViewById(R.id.item_search).setOnClickListener { findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToPatientList()) } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - (requireActivity() as MainActivity).openNavigationDrawer() - true - } - else -> false + requireView().findViewById(R.id.item_sync).setOnClickListener { + findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToSyncFragment()) } } } diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt index 422110d638..e96049fa22 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt @@ -17,78 +17,23 @@ package com.google.android.fhir.demo import android.os.Bundle -import android.view.MenuItem -import android.widget.TextView -import androidx.activity.viewModels -import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.GravityCompat -import androidx.drawerlayout.widget.DrawerLayout import com.google.android.fhir.demo.databinding.ActivityMainBinding const val MAX_RESOURCE_COUNT = 20 class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding - private lateinit var drawerToggle: ActionBarDrawerToggle - private val viewModel: MainActivityViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) initActionBar() - initNavigationDrawer() - observeLastSyncTime() - viewModel.updateLastSyncTimestamp() - } - - override fun onBackPressed() { - if (binding.drawer.isDrawerOpen(GravityCompat.START)) { - binding.drawer.closeDrawer(GravityCompat.START) - return - } - super.onBackPressed() - } - - fun setDrawerEnabled(enabled: Boolean) { - val lockMode = - if (enabled) DrawerLayout.LOCK_MODE_UNLOCKED else DrawerLayout.LOCK_MODE_LOCKED_CLOSED - binding.drawer.setDrawerLockMode(lockMode) - drawerToggle.isDrawerIndicatorEnabled = enabled - } - - fun openNavigationDrawer() { - binding.drawer.openDrawer(GravityCompat.START) - viewModel.updateLastSyncTimestamp() } private fun initActionBar() { val toolbar = binding.toolbar setSupportActionBar(toolbar) } - - private fun initNavigationDrawer() { - binding.navigationView.setNavigationItemSelectedListener(this::onNavigationItemSelected) - drawerToggle = ActionBarDrawerToggle(this, binding.drawer, R.string.open, R.string.close) - binding.drawer.addDrawerListener(drawerToggle) - drawerToggle.syncState() - } - - private fun onNavigationItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.menu_sync -> { - viewModel.triggerOneTimeSync() - binding.drawer.closeDrawer(GravityCompat.START) - return false - } - } - return false - } - - private fun observeLastSyncTime() { - viewModel.lastSyncTimestampLiveData.observe(this) { - binding.navigationView.getHeaderView(0).findViewById(R.id.last_sync_tv).text = it - } - } } diff --git a/demo/src/main/java/com/google/android/fhir/demo/PatientDetailsFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/PatientDetailsFragment.kt index 22ad52bfcb..db805fc64f 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/PatientDetailsFragment.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/PatientDetailsFragment.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -82,7 +82,6 @@ class PatientDetailsFragment : Fragment() { } } patientDetailsViewModel.getPatientDetailData() - (activity as MainActivity).setDrawerEnabled(false) } private fun onAddScreenerClick() { diff --git a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt index de218bfd46..b81fb97c97 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt @@ -64,7 +64,7 @@ class PatientListFragment : Fragment() { private val binding get() = _binding!! - private val mainActivityViewModel: MainActivityViewModel by activityViewModels() + private val mainActivityViewModel: SyncFragmentViewModel by activityViewModels() override fun onCreateView( inflater: LayoutInflater, @@ -153,7 +153,6 @@ class PatientListFragment : Fragment() { addPatient.setColorFilter(Color.WHITE) } setHasOptionsMenu(true) - (activity as MainActivity).setDrawerEnabled(false) launchAndRepeatStarted( { mainActivityViewModel.pollState.collect(::currentSyncJobStatus) }, { mainActivityViewModel.pollPeriodicSyncJobStatus.collect(::periodicSyncJobStatus) }, diff --git a/demo/src/main/java/com/google/android/fhir/demo/SyncFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/SyncFragment.kt new file mode 100644 index 0000000000..4aaafe8950 --- /dev/null +++ b/demo/src/main/java/com/google/android/fhir/demo/SyncFragment.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.demo + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ProgressBar +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.NavHostFragment +import com.google.android.fhir.demo.extensions.launchAndRepeatStarted +import com.google.android.fhir.sync.CurrentSyncJobStatus + +class SyncFragment : Fragment() { + private val syncFragmentViewModel: SyncFragmentViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + return inflater.inflate(R.layout.sync, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setUpActionBar() + setHasOptionsMenu(true) + view.findViewById