diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/PhoneNumberViewHolderFactoryInstrumentedTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/PhoneNumberViewHolderFactoryInstrumentedTest.kt index f0fcfcc94a..307a5738e3 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/PhoneNumberViewHolderFactoryInstrumentedTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/PhoneNumberViewHolderFactoryInstrumentedTest.kt @@ -31,8 +31,6 @@ import androidx.compose.ui.test.performTextReplacement import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.google.android.fhir.datacapture.QuestionnaireEditAdapter -import com.google.android.fhir.datacapture.QuestionnaireViewHolderType import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.test.TestActivity import com.google.android.fhir.datacapture.validation.Invalid @@ -83,25 +81,6 @@ class PhoneNumberViewHolderFactoryInstrumentedTest { composeTestRule.unregisterIdlingResource(handlingTextIdlingResource) } - @Test - fun createViewHolder_phoneNumberViewHolderFactory_returnsViewHolder() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val viewHolderFromAdapter = - questionnaireEditAdapter.createViewHolder( - parent, - QuestionnaireEditAdapter.ViewType.from( - type = QuestionnaireEditAdapter.ViewType.Type.QUESTION, - subtype = QuestionnaireViewHolderType.PHONE_NUMBER.value, - ) - .viewType, - ) as QuestionnaireEditAdapter.ViewHolder.QuestionHolder - - assertThat( - viewHolderFromAdapter.holder.itemView.visibility, - ) - .isEqualTo(View.VISIBLE) - } - @Test fun shouldSetTextViewText() { viewHolder.bind( diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt index eda368a443..8b7c2e27db 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/QuestionnaireUiEspressoTest.kt @@ -21,27 +21,20 @@ import android.widget.FrameLayout import android.widget.TextView import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals -import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performTextInput import androidx.fragment.app.commitNow -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.UiController -import androidx.test.espresso.ViewAction import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.assertion.ViewAssertions.doesNotExist -import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.RootMatchers import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText -import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SdkSuppress import androidx.test.platform.app.InstrumentationRegistry @@ -70,6 +63,7 @@ import java.util.Date import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.hamcrest.CoreMatchers +import org.hamcrest.Matchers.allOf import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.Questionnaire @@ -83,11 +77,7 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class QuestionnaireUiEspressoTest { - @get:Rule - val activityScenarioRule: ActivityScenarioRule = - ActivityScenarioRule(TestActivity::class.java) - - @get:Rule val composeTestRule = createEmptyComposeRule() + @get:Rule(order = 9) val composeTestRule = createAndroidComposeRule() private lateinit var parent: FrameLayout private val parser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() @@ -95,14 +85,14 @@ class QuestionnaireUiEspressoTest { @Before fun setup() { - activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) } + composeTestRule.activityRule.scenario.onActivity { activity -> parent = FrameLayout(activity) } } @Test fun shouldDisplayReviewButtonWhenNoMorePagesToDisplay() { buildFragmentFromQuestionnaire("/paginated_questionnaire_with_dependent_answer.json", true) - onView(withId(R.id.review_mode_button)) + onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button)) .check( ViewAssertions.matches( ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), @@ -110,13 +100,13 @@ class QuestionnaireUiEspressoTest { ) clickOnText("Yes") - onView(withId(R.id.review_mode_button)) + onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button)) .check( ViewAssertions.matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.GONE)), ) clickOnText("No") - onView(withId(R.id.review_mode_button)) + onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button)) .check( ViewAssertions.matches( ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), @@ -130,7 +120,7 @@ class QuestionnaireUiEspressoTest { clickOnText("Next") - onView(withId(R.id.pagination_next_button)) + onView(withId(com.google.android.fhir.datacapture.R.id.pagination_next_button)) .check( ViewAssertions.matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.GONE)), ) @@ -140,7 +130,7 @@ class QuestionnaireUiEspressoTest { fun shouldDisplayNextButtonIfEnabled() { buildFragmentFromQuestionnaire("/layout_paginated.json", true) - onView(withId(R.id.pagination_next_button)) + onView(withId(com.google.android.fhir.datacapture.R.id.pagination_next_button)) .check( ViewAssertions.matches( ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), @@ -227,31 +217,35 @@ class QuestionnaireUiEspressoTest { buildFragmentFromQuestionnaire("/component_date_time_picker.json") // Add month and day. No need to add slashes as they are added automatically - onView(withId(R.id.date_input_edit_text)) + onView(withId(com.google.android.fhir.datacapture.R.id.date_input_edit_text)) .perform(ViewActions.click()) .perform(ViewActions.typeTextIntoFocusedView("0105")) - onView(withId(R.id.date_input_layout)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.date_input_layout)).check { view, _ -> val actualError = (view as TextInputLayout).error assertThat(actualError).isEqualTo("Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)") } - onView(withId(R.id.time_input_layout)).check { view, _ -> assertThat(view.isEnabled).isFalse() } + onView(withId(com.google.android.fhir.datacapture.R.id.time_input_layout)).check { view, _ -> + assertThat(view.isEnabled).isFalse() + } } @Test fun dateTimePicker_shouldEnableTimePickerWithCorrectDate_butNotSaveInQuestionnaireResponse() { buildFragmentFromQuestionnaire("/component_date_time_picker.json") - onView(withId(R.id.date_input_edit_text)) + onView(withId(com.google.android.fhir.datacapture.R.id.date_input_edit_text)) .perform(ViewActions.click()) .perform(ViewActions.typeTextIntoFocusedView("01052005")) - onView(withId(R.id.date_input_layout)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.date_input_layout)).check { view, _ -> val actualError = (view as TextInputLayout).error assertThat(actualError).isEqualTo(null) } - onView(withId(R.id.time_input_layout)).check { view, _ -> assertThat(view.isEnabled).isTrue() } + onView(withId(com.google.android.fhir.datacapture.R.id.time_input_layout)).check { view, _ -> + assertThat(view.isEnabled).isTrue() + } runBlocking { assertThat(getQuestionnaireResponse().item.size).isEqualTo(1) @@ -263,11 +257,12 @@ class QuestionnaireUiEspressoTest { fun dateTimePicker_shouldSetAnswerWhenDateAndTimeAreFilled() { buildFragmentFromQuestionnaire("/component_date_time_picker.json") - onView(withId(R.id.date_input_edit_text)) + onView(withId(com.google.android.fhir.datacapture.R.id.date_input_edit_text)) .perform(ViewActions.click()) .perform(ViewActions.typeTextIntoFocusedView("01052005")) - onView(withId(R.id.time_input_layout)).perform(clickIcon(true)) + onView(withId(com.google.android.fhir.datacapture.R.id.time_input_layout)) + .perform(clickIcon(true)) clickOnText("AM") clickOnText("6") clickOnText("10") @@ -285,11 +280,11 @@ class QuestionnaireUiEspressoTest { buildFragmentFromQuestionnaire("/component_date_picker.json") // Add month and day. No need to add slashes as they are added automatically - onView(withId(R.id.text_input_edit_text)) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) .perform(ViewActions.click()) .perform(ViewActions.typeTextIntoFocusedView("0105")) - onView(withId(R.id.text_input_layout)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)).check { view, _ -> val actualError = (view as TextInputLayout).error assertThat(actualError).isEqualTo("Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)") } @@ -299,11 +294,11 @@ class QuestionnaireUiEspressoTest { fun datePicker_shouldSaveInQuestionnaireResponseWhenCorrectDateEntered() { buildFragmentFromQuestionnaire("/component_date_picker.json") - onView(withId(R.id.text_input_edit_text)) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) .perform(ViewActions.click()) .perform(ViewActions.typeTextIntoFocusedView("01052005")) - onView(withId(R.id.text_input_layout)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)).check { view, _ -> val actualError = (view as TextInputLayout).error assertThat(actualError).isEqualTo(null) } @@ -338,8 +333,9 @@ class QuestionnaireUiEspressoTest { } buildFragmentFromQuestionnaire(questionnaire) - onView(withId(R.id.text_input_layout)).perform(clickIcon(true)) - onView(CoreMatchers.allOf(ViewMatchers.withText("OK"))) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)) + .perform(clickIcon(true)) + onView(CoreMatchers.allOf(withText("OK"))) .inRoot(RootMatchers.isDialog()) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) .perform(ViewActions.click()) @@ -385,8 +381,9 @@ class QuestionnaireUiEspressoTest { } buildFragmentFromQuestionnaire(questionnaire) - onView(withId(R.id.text_input_layout)).perform(clickIcon(true)) - onView(CoreMatchers.allOf(ViewMatchers.withText("OK"))) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)) + .perform(clickIcon(true)) + onView(CoreMatchers.allOf(withText("OK"))) .inRoot(RootMatchers.isDialog()) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) .perform(ViewActions.click()) @@ -432,8 +429,9 @@ class QuestionnaireUiEspressoTest { } buildFragmentFromQuestionnaire(questionnaire) - onView(withId(R.id.text_input_layout)).perform(clickIcon(true)) - onView(CoreMatchers.allOf(ViewMatchers.withText("OK"))) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)) + .perform(clickIcon(true)) + onView(CoreMatchers.allOf(withText("OK"))) .inRoot(RootMatchers.isDialog()) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) .perform(ViewActions.click()) @@ -484,7 +482,7 @@ class QuestionnaireUiEspressoTest { Assert.assertThrows(IllegalArgumentException::class.java) { onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)) .perform(clickIcon(true)) - onView(CoreMatchers.allOf(ViewMatchers.withText("OK"))) + onView(CoreMatchers.allOf(withText("OK"))) .inRoot(RootMatchers.isDialog()) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) .perform(ViewActions.click()) @@ -496,32 +494,35 @@ class QuestionnaireUiEspressoTest { fun displayItems_shouldGetEnabled_withAnswerChoice() { buildFragmentFromQuestionnaire("/questionnaire_with_enabled_display_items.json") - onView(withId(R.id.hint)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.hint)).check { view, _ -> val hintVisibility = (view as TextView).visibility assertThat(hintVisibility).isEqualTo(View.GONE) } - onView(withId(R.id.yes_radio_button)).perform(ViewActions.click()) + onView(withId(com.google.android.fhir.datacapture.R.id.yes_radio_button)) + .perform(ViewActions.click()) - onView(withId(R.id.hint)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.hint)).check { view, _ -> val hintVisibility = (view as TextView).visibility val hintText = view.text.toString() assertThat(hintVisibility).isEqualTo(View.VISIBLE) assertThat(hintText).isEqualTo("Text when yes is selected") } - onView(withId(R.id.no_radio_button)).perform(ViewActions.click()) + onView(withId(com.google.android.fhir.datacapture.R.id.no_radio_button)) + .perform(ViewActions.click()) - onView(withId(R.id.hint)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.hint)).check { view, _ -> val hintVisibility = (view as TextView).visibility val hintText = view.text.toString() assertThat(hintVisibility).isEqualTo(View.VISIBLE) assertThat(hintText).isEqualTo("Text when no is selected") } - onView(withId(R.id.no_radio_button)).perform(ViewActions.click()) + onView(withId(com.google.android.fhir.datacapture.R.id.no_radio_button)) + .perform(ViewActions.click()) - onView(withId(R.id.hint)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.hint)).check { view, _ -> val hintVisibility = (view as TextView).visibility assertThat(hintVisibility).isEqualTo(View.GONE) } @@ -532,7 +533,7 @@ class QuestionnaireUiEspressoTest { buildFragmentFromQuestionnaire("/questionnaire_with_dynamic_question_text.json") onView(CoreMatchers.allOf(withText("Option Date"))).check { view, _ -> - assertThat(view.id).isEqualTo(R.id.question) + assertThat(view.id).isEqualTo(com.google.android.fhir.datacapture.R.id.question) } onView(CoreMatchers.allOf(withText("Provide \"First Option\" Date"))).check { view, _ -> @@ -546,7 +547,7 @@ class QuestionnaireUiEspressoTest { } onView(CoreMatchers.allOf(withText("Provide \"First Option\" Date"))).check { view, _ -> - assertThat(view.id).isEqualTo(R.id.question) + assertThat(view.id).isEqualTo(com.google.android.fhir.datacapture.R.id.question) } } @@ -555,13 +556,13 @@ class QuestionnaireUiEspressoTest { fun clearAllAnswers_shouldClearDraftAnswer() { val questionnaireFragment = buildFragmentFromQuestionnaire("/component_date_picker.json") // Add month and day. No need to add slashes as they are added automatically - onView(withId(R.id.text_input_edit_text)) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) .perform(ViewActions.click()) .perform(ViewActions.typeTextIntoFocusedView("0105")) questionnaireFragment.clearAllAnswers() - onView(withId(R.id.text_input_edit_text)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)).check { view, _ -> assertThat((view as TextInputEditText).text.toString()).isEmpty() } } @@ -570,164 +571,110 @@ class QuestionnaireUiEspressoTest { fun progressBar_shouldBeVisible_withSinglePageQuestionnaire() { buildFragmentFromQuestionnaire("/text_questionnaire_integer.json") - onView(withId(R.id.questionnaire_progress_indicator)).check { view, _ -> - val linearProgressIndicator = (view as LinearProgressIndicator) - assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) - assertThat(linearProgressIndicator.progress).isEqualTo(100) - } + onView(withId(com.google.android.fhir.datacapture.R.id.questionnaire_progress_indicator)) + .check { view, _ -> + val linearProgressIndicator = (view as LinearProgressIndicator) + assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) + assertThat(linearProgressIndicator.progress).isEqualTo(100) + } } @Test fun progressBar_shouldBeVisible_withPaginatedQuestionnaire() { buildFragmentFromQuestionnaire("/layout_paginated.json") - onView(withId(R.id.questionnaire_progress_indicator)).check { view, _ -> - val linearProgressIndicator = (view as LinearProgressIndicator) - assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) - assertThat(linearProgressIndicator.progress).isEqualTo(50) - } + onView(withId(com.google.android.fhir.datacapture.R.id.questionnaire_progress_indicator)) + .check { view, _ -> + val linearProgressIndicator = (view as LinearProgressIndicator) + assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) + assertThat(linearProgressIndicator.progress).isEqualTo(50) + } } @Test fun progressBar_shouldProgress_onPaginationNext() { buildFragmentFromQuestionnaire("/layout_paginated.json") - onView(withId(R.id.pagination_next_button)).perform(ViewActions.click()) + onView(withId(com.google.android.fhir.datacapture.R.id.pagination_next_button)) + .perform(ViewActions.click()) - onView(withId(R.id.questionnaire_progress_indicator)).check { view, _ -> - val linearProgressIndicator = (view as LinearProgressIndicator) - assertThat(linearProgressIndicator.progress).isEqualTo(100) - } + onView(withId(com.google.android.fhir.datacapture.R.id.questionnaire_progress_indicator)) + .check { view, _ -> + val linearProgressIndicator = (view as LinearProgressIndicator) + assertThat(linearProgressIndicator.progress).isEqualTo(100) + } } @Test fun progressBar_shouldBeGone_whenNavigatedToReviewScreen() { buildFragmentFromQuestionnaire("/text_questionnaire_integer.json", isReviewMode = true) - onView(withId(R.id.review_mode_button)).perform(ViewActions.click()) + onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button)) + .perform(ViewActions.click()) - onView(withId(R.id.questionnaire_progress_indicator)).check { view, _ -> - val linearProgressIndicator = (view as LinearProgressIndicator) - assertThat(linearProgressIndicator.visibility).isEqualTo(View.GONE) - } + onView(withId(com.google.android.fhir.datacapture.R.id.questionnaire_progress_indicator)) + .check { view, _ -> + val linearProgressIndicator = (view as LinearProgressIndicator) + assertThat(linearProgressIndicator.visibility).isEqualTo(View.GONE) + } } @Test fun progressBar_shouldBeVisible_whenNavigatedToEditScreenFromReview() { buildFragmentFromQuestionnaire("/text_questionnaire_integer.json", isReviewMode = true) - onView(withId(R.id.review_mode_button)).perform(ViewActions.click()) + onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_button)) + .perform(ViewActions.click()) - onView(withId(R.id.review_mode_edit_button)).perform(ViewActions.click()) + onView(withId(com.google.android.fhir.datacapture.R.id.review_mode_edit_button)) + .perform(ViewActions.click()) - onView(withId(R.id.questionnaire_progress_indicator)).check { view, _ -> - val linearProgressIndicator = (view as LinearProgressIndicator) - assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) - } + onView(withId(com.google.android.fhir.datacapture.R.id.questionnaire_progress_indicator)) + .check { view, _ -> + val linearProgressIndicator = (view as LinearProgressIndicator) + assertThat(linearProgressIndicator.visibility).isEqualTo(View.VISIBLE) + } } @Test fun test_add_item_button_does_not_exist_for_non_repeated_groups() { buildFragmentFromQuestionnaire("/component_non_repeated_group.json") - onView(withId(R.id.add_item_to_repeated_group)).check(doesNotExist()) + onView(withId(com.google.android.fhir.datacapture.R.id.add_item_to_repeated_group)) + .check(doesNotExist()) } @Test fun test_repeated_group_is_added() { buildFragmentFromQuestionnaire("/component_repeated_group.json") + onView(withId(com.google.android.fhir.datacapture.R.id.add_item_to_repeated_group)) + .perform(ViewActions.click()) - onView(withId(R.id.questionnaire_edit_recycler_view)) - .perform( - RecyclerViewActions.actionOnItemAtPosition( - 1, // 'Add item' is in the second row of the recyclerview with group header as the first - // item - clickChildViewWithId(R.id.add_item_to_repeated_group), - ), - ) + composeTestRule + .onNodeWithTag(QuestionnaireFragment.QUESTIONNAIRE_EDIT_LIST) + .assertExists() + .assertIsDisplayed() - onView(ViewMatchers.withId(R.id.questionnaire_edit_recycler_view)).check { - view, - noViewFoundException, - -> - if (noViewFoundException != null) { - throw noViewFoundException - } - assertThat( - (view as RecyclerView).countChildViewOccurrences( - R.id.repeated_group_instance_header_title, - ), - ) - .isEqualTo(1) - } + onView(withId(com.google.android.fhir.datacapture.R.id.repeated_group_instance_header_title)) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + + onView(withText(com.google.android.fhir.datacapture.R.string.delete)) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) } @Test fun test_repeated_group_adds_multiple_items() { buildFragmentFromQuestionnaire("/component_multiple_repeated_group.json") - onView(withId(R.id.questionnaire_edit_recycler_view)) - .perform( - RecyclerViewActions.actionOnItemAtPosition( - 1, // The add button position is 1 (zero-indexed) after the group's header - clickChildViewWithId(R.id.add_item_to_repeated_group), - ), - ) - .perform( - RecyclerViewActions.actionOnItemAtPosition( - 3, // The add button new position becomes 3 (zero-indexed) after the group's header, - // repeated item's header and the one item added - clickChildViewWithId(R.id.add_item_to_repeated_group), - ), - ) + onView(allOf(withText("Add Repeated Group"))).perform(ViewActions.click()) - onView(ViewMatchers.withId(R.id.questionnaire_edit_recycler_view)).check { - view, - noViewFoundException, - -> - if (noViewFoundException != null) { - throw noViewFoundException - } - assertThat( - (view as RecyclerView).countChildViewOccurrences( - R.id.repeated_group_instance_header_title, - ), - ) - .isEqualTo(2) - } - } + onView(allOf(withText(com.google.android.fhir.datacapture.R.string.delete))) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - @Test - fun test_repeated_group_adds_items_for_subsequent() { - buildFragmentFromQuestionnaire("/component_multiple_repeated_group.json") - onView(withId(R.id.questionnaire_edit_recycler_view)) - .perform( - RecyclerViewActions.actionOnItemAtPosition( - 3, // The add button for the second repeated group is at position 3 (zero-indexed), after - // the first group's header (0), the first group's add button (1), and the second - // group's header (2) - clickChildViewWithId(R.id.add_item_to_repeated_group), + onView( + allOf( + withId(com.google.android.fhir.datacapture.R.id.repeated_group_instance_header_title), ), ) - .perform( - RecyclerViewActions.actionOnItemAtPosition( - 5, // The add button for the second group is now at position 5 after adding one item - clickChildViewWithId(R.id.add_item_to_repeated_group), - ), - ) - - onView(ViewMatchers.withId(R.id.questionnaire_edit_recycler_view)).check { - view, - noViewFoundException, - -> - if (noViewFoundException != null) { - throw noViewFoundException - } - assertThat( - (view as RecyclerView).countChildViewOccurrences( - R.id.repeated_group_instance_header_title, - ), - ) - .isEqualTo(2) - } + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) } @Test @@ -737,51 +684,20 @@ class QuestionnaireUiEspressoTest { responseFileName = "/repeated_group_response.json", ) - onView(withId(R.id.questionnaire_edit_recycler_view)) - .perform( - RecyclerViewActions.actionOnItemAtPosition( - 1, - clickChildViewWithId(R.id.repeated_group_instance_header_delete_button), - ), - ) - - onView(ViewMatchers.withId(R.id.questionnaire_edit_recycler_view)).check { - view, - noViewFoundException, - -> - if (noViewFoundException != null) { - throw noViewFoundException - } - assertThat( - (view as RecyclerView).countChildViewOccurrences( - R.id.repeated_group_instance_header_title, - ), - ) - .isEqualTo(0) - } - } + composeTestRule + .onNodeWithTag(QuestionnaireFragment.QUESTIONNAIRE_EDIT_LIST) + .assertExists() + .assertIsDisplayed() - private fun RecyclerView.countChildViewOccurrences(viewId: Int): Int { - var count = 0 - for (i in 0 until this.adapter!!.itemCount) { - val holder = findViewHolderForAdapterPosition(i) - if (holder?.itemView?.findViewById(viewId) != null) { - count++ - } - } - return count - } - - private fun clickChildViewWithId(id: Int) = - object : ViewAction { - override fun getConstraints() = isAssignableFrom(View::class.java) + onView(withId(com.google.android.fhir.datacapture.R.id.repeated_group_instance_header_title)) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) - override fun getDescription() = "Click on a child view with specified id." + onView(withText(com.google.android.fhir.datacapture.R.string.delete)) + .perform(ViewActions.click()) - override fun perform(uiController: UiController?, view: View) { - view.findViewById(id)?.performClick() - } - } + onView(withText(com.google.android.fhir.datacapture.R.id.repeated_group_instance_header_title)) + .check(doesNotExist()) + } private fun buildFragmentFromQuestionnaire( fileName: String, @@ -798,7 +714,7 @@ class QuestionnaireUiEspressoTest { responseFileName?.let { builder.setQuestionnaireResponse(readFileFromAssets(it)) } return builder.build().also { fragment -> - activityScenarioRule.scenario.onActivity { activity -> + composeTestRule.activityRule.scenario.onActivity { activity -> activity.supportFragmentManager.commitNow { setReorderingAllowed(true) add(R.id.container_holder, fragment) @@ -816,7 +732,7 @@ class QuestionnaireUiEspressoTest { .setQuestionnaire(parser.encodeResourceToString(questionnaire)) .showReviewPageBeforeSubmit(isReviewMode) .build() - activityScenarioRule.scenario.onActivity { activity -> + composeTestRule.activityRule.scenario.onActivity { activity -> activity.supportFragmentManager.commitNow { setReorderingAllowed(true) add(R.id.container_holder, questionnaireFragment) @@ -829,7 +745,7 @@ class QuestionnaireUiEspressoTest { private suspend fun getQuestionnaireResponse(): QuestionnaireResponse { var testQuestionnaireFragment: QuestionnaireFragment? = null - activityScenarioRule.scenario.onActivity { activity -> + composeTestRule.activityRule.scenario.onActivity { activity -> testQuestionnaireFragment = activity.supportFragmentManager.findFragmentById(R.id.container_holder) as QuestionnaireFragment diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt deleted file mode 100644 index d5243482c6..0000000000 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt +++ /dev/null @@ -1,442 +0,0 @@ -/* - * Copyright 2022-2025 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.datacapture - -import android.annotation.SuppressLint -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.google.android.fhir.datacapture.contrib.views.PhoneNumberViewHolderFactory -import com.google.android.fhir.datacapture.extensions.inflate -import com.google.android.fhir.datacapture.extensions.itemControl -import com.google.android.fhir.datacapture.extensions.shouldUseDialog -import com.google.android.fhir.datacapture.views.NavigationViewHolder -import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.fhir.datacapture.views.RepeatedGroupAddItemViewHolder -import com.google.android.fhir.datacapture.views.factories.AttachmentViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.AutoCompleteViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.BooleanChoiceViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.CheckBoxGroupViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.DatePickerViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.DateTimePickerViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.DisplayViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.DropDownViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.EditTextDecimalViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.EditTextIntegerViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.EditTextMultiLineViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.EditTextSingleLineViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.GroupViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.QuantityViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemDialogSelectViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder -import com.google.android.fhir.datacapture.views.factories.RadioGroupViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.RepeatedGroupHeaderItemViewHolder -import com.google.android.fhir.datacapture.views.factories.SliderViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.TimePickerViewHolderFactory -import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemType - -internal class QuestionnaireEditAdapter( - private val questionnaireItemViewHolderMatchers: - List = - emptyList(), -) : - ListAdapter(DiffCallbacks.ITEMS) { - /** - * @param viewType the integer value of the [QuestionnaireViewHolderType] used to render the - * [QuestionnaireViewItem]. - */ - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val typedViewType = ViewType.parse(viewType) - val subtype = typedViewType.subtype - return when (typedViewType.type) { - ViewType.Type.QUESTION -> - ViewHolder.QuestionHolder(onCreateViewHolderQuestion(parent = parent, subtype = subtype)) - ViewType.Type.REPEATED_GROUP_HEADER -> { - ViewHolder.RepeatedGroupHeaderHolder( - RepeatedGroupHeaderItemViewHolder( - parent.inflate(R.layout.repeated_group_instance_header_view), - ), - ) - } - ViewType.Type.NAVIGATION -> { - ViewHolder.NavigationHolder( - NavigationViewHolder( - parent.inflate(R.layout.pagination_navigation_view), - ), - ) - } - ViewType.Type.REPEATED_GROUP_ADD_BUTTON -> { - ViewHolder.RepeatedGroupAddButtonViewHolder( - RepeatedGroupAddItemViewHolder.create(parent), - ) - } - } - } - - private fun onCreateViewHolderQuestion( - parent: ViewGroup, - subtype: Int, - ): QuestionnaireItemViewHolder { - val numOfCanonicalWidgets = QuestionnaireViewHolderType.values().size - check(subtype < numOfCanonicalWidgets + questionnaireItemViewHolderMatchers.size) { - "Invalid widget type specified. Widget Int type cannot exceed the total number of supported custom and canonical widgets" - } - - // Map custom widget viewTypes to their corresponding widget factories - if (subtype >= numOfCanonicalWidgets) { - return questionnaireItemViewHolderMatchers[subtype - numOfCanonicalWidgets] - .factory - .create(parent) - } - - val viewHolderFactory = - when (QuestionnaireViewHolderType.fromInt(subtype)) { - QuestionnaireViewHolderType.GROUP -> GroupViewHolderFactory - QuestionnaireViewHolderType.BOOLEAN_TYPE_PICKER -> BooleanChoiceViewHolderFactory - QuestionnaireViewHolderType.DATE_PICKER -> DatePickerViewHolderFactory - QuestionnaireViewHolderType.TIME_PICKER -> TimePickerViewHolderFactory - QuestionnaireViewHolderType.DATE_TIME_PICKER -> DateTimePickerViewHolderFactory - QuestionnaireViewHolderType.EDIT_TEXT_SINGLE_LINE -> EditTextSingleLineViewHolderFactory - QuestionnaireViewHolderType.EDIT_TEXT_MULTI_LINE -> EditTextMultiLineViewHolderFactory - QuestionnaireViewHolderType.EDIT_TEXT_INTEGER -> EditTextIntegerViewHolderFactory - QuestionnaireViewHolderType.EDIT_TEXT_DECIMAL -> EditTextDecimalViewHolderFactory - QuestionnaireViewHolderType.RADIO_GROUP -> RadioGroupViewHolderFactory - QuestionnaireViewHolderType.DROP_DOWN -> DropDownViewHolderFactory - QuestionnaireViewHolderType.DISPLAY -> DisplayViewHolderFactory - QuestionnaireViewHolderType.QUANTITY -> QuantityViewHolderFactory - QuestionnaireViewHolderType.CHECK_BOX_GROUP -> CheckBoxGroupViewHolderFactory - QuestionnaireViewHolderType.AUTO_COMPLETE -> AutoCompleteViewHolderFactory - QuestionnaireViewHolderType.DIALOG_SELECT -> QuestionnaireItemDialogSelectViewHolderFactory - QuestionnaireViewHolderType.SLIDER -> SliderViewHolderFactory - QuestionnaireViewHolderType.PHONE_NUMBER -> PhoneNumberViewHolderFactory - QuestionnaireViewHolderType.ATTACHMENT -> AttachmentViewHolderFactory - } - return viewHolderFactory.create(parent) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - when (val item = getItem(position)) { - is QuestionnaireAdapterItem.Question -> { - holder as ViewHolder.QuestionHolder - holder.holder.bind(item.item) - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> { - holder as ViewHolder.RepeatedGroupHeaderHolder - holder.viewHolder.bind(item) - } - is QuestionnaireAdapterItem.Navigation -> { - holder as ViewHolder.NavigationHolder - holder.viewHolder.bind(item.questionnaireNavigationUIState) - } - is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { - holder as ViewHolder.RepeatedGroupAddButtonViewHolder - holder.viewHolder.bind(item.item) - } - } - } - - override fun getItemViewType(position: Int): Int { - // Because we have multiple Item subtypes, we will pack two ints into the item view type. - - // The first 8 bits will be represented by this type, which is unique for each Item subclass. - val type: ViewType.Type - // The last 24 bits will be represented by this subtype, which will further divide each Item - // subclass into more view types. - val subtype: Int - when (val item = getItem(position)) { - is QuestionnaireAdapterItem.Question -> { - type = ViewType.Type.QUESTION - subtype = getItemViewTypeForQuestion(item.item) - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> { - type = ViewType.Type.REPEATED_GROUP_HEADER - // All of the repeated group headers will be rendered identically - subtype = 0 - } - is QuestionnaireAdapterItem.Navigation -> { - type = ViewType.Type.NAVIGATION - subtype = 0xFFFFFF - } - is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { - type = ViewType.Type.REPEATED_GROUP_ADD_BUTTON - subtype = 0 - } - } - return ViewType.from(type = type, subtype = subtype).viewType - } - - /** - * Utility to pack two types (a "type" and "subtype") into a single "viewType" int, for use with - * [getItemViewType]. - * - * [type] is contained in the first 8 bits of the int, and should be unique for each type of - * [QuestionnaireAdapterItem]. - * - * [subtype] is contained in the lower 24 bits of the int, and should be used to differentiate - * between different items within the same [QuestionnaireAdapterItem] type. - */ - @JvmInline - internal value class ViewType(val viewType: Int) { - val subtype: Int - get() = viewType and 0xFFFFFF - - val type: Type - get() = Type.values()[viewType shr 24] - - companion object { - fun parse(viewType: Int): ViewType = ViewType(viewType) - - fun from(type: Type, subtype: Int): ViewType = ViewType((type.ordinal shl 24) or subtype) - } - - enum class Type { - QUESTION, - REPEATED_GROUP_HEADER, - REPEATED_GROUP_ADD_BUTTON, - NAVIGATION, - } - } - - /** - * Returns the integer value of the [QuestionnaireViewHolderType] that will be used to render the - * [QuestionnaireViewItem]. This is determined by a combination of the data type of the question - * and any additional Questionnaire Item UI Control Codes - * (http://hl7.org/fhir/R4/valueset-questionnaire-item-control.html) used in the itemControl - * extension (http://hl7.org/fhir/R4/extension-questionnaire-itemcontrol.html). - */ - internal fun getItemViewTypeForQuestion( - questionnaireViewItem: QuestionnaireViewItem, - ): Int { - val questionnaireItem = questionnaireViewItem.questionnaireItem - // For custom widgets, generate an int value that's greater than any int assigned to the - // canonical FHIR widgets - questionnaireItemViewHolderMatchers.forEachIndexed { index, matcher -> - if (matcher.matches(questionnaireItem)) { - return index + QuestionnaireViewHolderType.values().size - } - } - - if (questionnaireViewItem.enabledAnswerOptions.isNotEmpty()) { - return getChoiceViewHolderType(questionnaireViewItem).value - } - - return when (val type = questionnaireItem.type) { - QuestionnaireItemType.GROUP -> QuestionnaireViewHolderType.GROUP - QuestionnaireItemType.BOOLEAN -> QuestionnaireViewHolderType.BOOLEAN_TYPE_PICKER - QuestionnaireItemType.DATE -> QuestionnaireViewHolderType.DATE_PICKER - QuestionnaireItemType.TIME -> QuestionnaireViewHolderType.TIME_PICKER - QuestionnaireItemType.DATETIME -> QuestionnaireViewHolderType.DATE_TIME_PICKER - QuestionnaireItemType.STRING -> getStringViewHolderType(questionnaireViewItem) - QuestionnaireItemType.TEXT -> QuestionnaireViewHolderType.EDIT_TEXT_MULTI_LINE - QuestionnaireItemType.INTEGER -> getIntegerViewHolderType(questionnaireViewItem) - QuestionnaireItemType.DECIMAL -> QuestionnaireViewHolderType.EDIT_TEXT_DECIMAL - QuestionnaireItemType.CHOICE -> getChoiceViewHolderType(questionnaireViewItem) - QuestionnaireItemType.DISPLAY -> QuestionnaireViewHolderType.DISPLAY - QuestionnaireItemType.QUANTITY -> QuestionnaireViewHolderType.QUANTITY - QuestionnaireItemType.REFERENCE -> getChoiceViewHolderType(questionnaireViewItem) - QuestionnaireItemType.ATTACHMENT -> QuestionnaireViewHolderType.ATTACHMENT - else -> throw NotImplementedError("Question type $type not supported.") - }.value - } - - private fun getChoiceViewHolderType( - questionnaireViewItem: QuestionnaireViewItem, - ): QuestionnaireViewHolderType { - val questionnaireItem = questionnaireViewItem.questionnaireItem - - // Use the view type that the client wants if they specified an itemControl or dialog extension - return when { - questionnaireItem.shouldUseDialog -> QuestionnaireViewHolderType.DIALOG_SELECT - else -> questionnaireItem.itemControl?.viewHolderType - } - // Otherwise, choose a sensible UI element automatically - ?: run { - val numOptions = questionnaireViewItem.enabledAnswerOptions.size - when { - // Always use a dialog for questions with a large number of options - numOptions >= MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DIALOG -> - QuestionnaireViewHolderType.DIALOG_SELECT - - // Use a check box group if repeated answers are permitted - questionnaireItem.repeats -> QuestionnaireViewHolderType.CHECK_BOX_GROUP - - // Use a dropdown if there are a medium number of options - numOptions >= MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN -> - QuestionnaireViewHolderType.DROP_DOWN - - // Use a radio group only if there are a small number of options - else -> QuestionnaireViewHolderType.RADIO_GROUP - } - } - } - - private fun getIntegerViewHolderType( - questionnaireViewItem: QuestionnaireViewItem, - ): QuestionnaireViewHolderType { - val questionnaireItem = questionnaireViewItem.questionnaireItem - // Use the view type that the client wants if they specified an itemControl - return questionnaireItem.itemControl?.viewHolderType - ?: QuestionnaireViewHolderType.EDIT_TEXT_INTEGER - } - - private fun getStringViewHolderType( - questionnaireViewItem: QuestionnaireViewItem, - ): QuestionnaireViewHolderType { - val questionnaireItem = questionnaireViewItem.questionnaireItem - // Use the view type that the client wants if they specified an itemControl - return questionnaireItem.itemControl?.viewHolderType - ?: QuestionnaireViewHolderType.EDIT_TEXT_SINGLE_LINE - } - - internal sealed class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - class QuestionHolder(val holder: QuestionnaireItemViewHolder) : ViewHolder(holder.itemView) - - class RepeatedGroupHeaderHolder(val viewHolder: RepeatedGroupHeaderItemViewHolder) : - ViewHolder(viewHolder.itemView) - - class NavigationHolder(val viewHolder: NavigationViewHolder) : ViewHolder(viewHolder.itemView) - - class RepeatedGroupAddButtonViewHolder(val viewHolder: RepeatedGroupAddItemViewHolder) : - ViewHolder(viewHolder.itemView) - } - - internal companion object { - // Choice questions are rendered as dialogs if they have at least this many options - const val MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DIALOG = 10 - - // Choice questions are rendered as radio group if number of options less than this constant - const val MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN = 4 - } -} - -internal object DiffCallbacks { - val ITEMS = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: QuestionnaireAdapterItem, - newItem: QuestionnaireAdapterItem, - ): Boolean = - when (oldItem) { - is QuestionnaireAdapterItem.Question -> { - newItem is QuestionnaireAdapterItem.Question && - QUESTIONS.areItemsTheSame(oldItem, newItem) - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> { - newItem is QuestionnaireAdapterItem.RepeatedGroupHeader && - oldItem.index == newItem.index - } - is QuestionnaireAdapterItem.Navigation -> newItem is QuestionnaireAdapterItem.Navigation - is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { - newItem is QuestionnaireAdapterItem.RepeatedGroupAddButton && - oldItem.item.hasTheSameItem(newItem.item) - } - } - - override fun areContentsTheSame( - oldItem: QuestionnaireAdapterItem, - newItem: QuestionnaireAdapterItem, - ): Boolean = - when (oldItem) { - is QuestionnaireAdapterItem.Question -> { - newItem is QuestionnaireAdapterItem.Question && - QUESTIONS.areContentsTheSame(oldItem, newItem) - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> { - if (newItem is QuestionnaireAdapterItem.RepeatedGroupHeader) { - // The `onDeleteClicked` function is a function closure generated in the questionnaire - // viewmodel with a reference to the parent questionnaire view item. When it is - // invoked, it deletes the current repeated group instance from the parent - // questionnaire view item by removing it from the list of children in the parent - // questionnaire view. - // In other words, although the `onDeleteClicked` function is not a data field, it is - // a function closure with references to data structures. Because - // `RepeatedGroupHeader` does not include any other data fields besides the index, it - // is particularly important to distinguish between different `RepeatedGroupHeader`s - // by the `onDeleteClicked` function. - // If this check is not here, an old RepeatedGroupHeader might be mistakenly - // considered up-to-date and retained in the recycler view even though a newer - // version includes a different `onDeleteClicked` function referencing a parent item - // with a different list of children. As a result clicking the delete function might - // result in deleting from an old list. - @SuppressLint("DiffUtilEquals") - val onDeleteClickedCallbacksEqual = oldItem.onDeleteClicked == newItem.onDeleteClicked - onDeleteClickedCallbacksEqual - } else { - false - } - } - is QuestionnaireAdapterItem.Navigation -> { - newItem is QuestionnaireAdapterItem.Navigation && - oldItem.questionnaireNavigationUIState == newItem.questionnaireNavigationUIState - } - is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { - newItem is QuestionnaireAdapterItem.RepeatedGroupAddButton && - oldItem.item.hasTheSameItem(newItem.item) && - oldItem.item.hasTheSameResponse(newItem.item) && - oldItem.item.hasTheSameValidationResult(newItem.item) - } - } - } - - val QUESTIONS = - object : DiffUtil.ItemCallback() { - /** - * [QuestionnaireViewItem] is a transient object for the UI only. Whenever the user makes any - * change via the UI, a new list of [QuestionnaireViewItem]s will be created, each holding - * references to the underlying [QuestionnaireItem] and [QuestionnaireResponseItem], both of - * which should be read-only, and the current answers. To help recycler view handle update - * and/or animations, we consider two [QuestionnaireViewItem]s to be the same if they have the - * same underlying [QuestionnaireItem] and [QuestionnaireResponseItem]. - */ - override fun areItemsTheSame( - oldItem: QuestionnaireAdapterItem.Question, - newItem: QuestionnaireAdapterItem.Question, - ) = oldItem.item.hasTheSameItem(newItem.item) - - override fun areContentsTheSame( - oldItem: QuestionnaireAdapterItem.Question, - newItem: QuestionnaireAdapterItem.Question, - ): Boolean { - return oldItem.item.hasTheSameItem(newItem.item) && - oldItem.item.hasTheSameResponse(newItem.item) && - oldItem.item.hasTheSameValidationResult(newItem.item) - } - } - - val REVIEW_ITEMS = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: ReviewAdapterItem, - newItem: ReviewAdapterItem, - ): Boolean = - ITEMS.areItemsTheSame( - oldItem as QuestionnaireAdapterItem, - newItem as QuestionnaireAdapterItem, - ) - - override fun areContentsTheSame( - oldItem: ReviewAdapterItem, - newItem: ReviewAdapterItem, - ): Boolean = - ITEMS.areContentsTheSame( - oldItem as QuestionnaireAdapterItem, - newItem as QuestionnaireAdapterItem, - ) - } -} 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 c74f901f33..e94e5359eb 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 @@ -21,31 +21,26 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.LinearLayout import android.widget.TextView import androidx.annotation.VisibleForTesting import androidx.appcompat.view.ContextThemeWrapper -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.res.use import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.android.fhir.datacapture.extensions.inflate +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_JSON_STRING +import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_JSON_URI +import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING +import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.views.NavigationViewHolder import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderFactory -import com.google.android.fhir.datacapture.views.factories.ReviewViewHolderFactory import com.google.android.material.progressindicator.LinearProgressIndicator import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Questionnaire @@ -101,8 +96,8 @@ class QuestionnaireFragment : Fragment() { /** @suppress */ override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val questionnaireEditRecyclerView = - view.findViewById(R.id.questionnaire_edit_recycler_view) + val questionnaireEditComposeView = + view.findViewById(R.id.questionnaire_edit_compose_view) val questionnaireReviewComposeView = view.findViewById(R.id.questionnaire_review_recycler_view) val questionnaireTitle = view.findViewById(R.id.questionnaire_title) @@ -147,103 +142,97 @@ class QuestionnaireFragment : Fragment() { } val questionnaireProgressIndicator: LinearProgressIndicator = view.findViewById(R.id.questionnaire_progress_indicator) - val questionnaireEditAdapter = - QuestionnaireEditAdapter(questionnaireItemViewHolderFactoryMatchersProvider.get()) val reviewModeEditButton = view.findViewById(R.id.review_mode_edit_button).apply { setOnClickListener { viewModel.setReviewMode(false) } } - questionnaireEditRecyclerView.adapter = questionnaireEditAdapter - val linearLayoutManager = LinearLayoutManager(view.context) - questionnaireEditRecyclerView.layoutManager = linearLayoutManager - // Animation does work well with views that could gain focus - questionnaireEditRecyclerView.itemAnimator = null - // Listen to updates from the view model. - viewLifecycleOwner.lifecycleScope.launchWhenCreated { - viewModel.questionnaireStateFlow.collect { state -> - when (val displayMode = state.displayMode) { - is DisplayMode.ReviewMode -> { - // Set items - questionnaireEditRecyclerView.visibility = View.GONE - - questionnaireReviewComposeView.visibility = View.VISIBLE - questionnaireReviewComposeView.setContent { QuestionnaireReviewList(state.items) } - reviewModeEditButton.visibility = - if (displayMode.showEditButton) { - View.VISIBLE + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { + viewModel.questionnaireStateFlow.collect { state -> + when (val displayMode = state.displayMode) { + is DisplayMode.ReviewMode -> { + // Set items + + questionnaireReviewComposeView.visibility = View.VISIBLE + questionnaireReviewComposeView.setContent { QuestionnaireReviewList(state.items) } + questionnaireEditComposeView.visibility = View.GONE + + reviewModeEditButton.visibility = + if (displayMode.showEditButton) { + View.VISIBLE + } else { + View.GONE + } + questionnaireTitle.visibility = View.VISIBLE + questionnaireTitle.text = getString(R.string.questionnaire_review_mode_title) + + // Set bottom navigation + if (state.bottomNavItem != null) { + bottomNavContainerFrame.visibility = View.VISIBLE + NavigationViewHolder(bottomNavContainerFrame) + .bind(state.bottomNavItem.questionnaireNavigationUIState) } else { - View.GONE + bottomNavContainerFrame.visibility = View.GONE } - questionnaireTitle.visibility = View.VISIBLE - questionnaireTitle.text = getString(R.string.questionnaire_review_mode_title) - - // Set bottom navigation - if (state.bottomNavItem != null) { - bottomNavContainerFrame.visibility = View.VISIBLE - NavigationViewHolder(bottomNavContainerFrame) - .bind(state.bottomNavItem.questionnaireNavigationUIState) - } else { - bottomNavContainerFrame.visibility = View.GONE - } - // Hide progress indicator - questionnaireProgressIndicator.visibility = View.GONE - } - is DisplayMode.EditMode -> { - // Set items - questionnaireReviewComposeView.visibility = View.GONE - questionnaireEditAdapter.submitList(state.items) - questionnaireEditRecyclerView.visibility = View.VISIBLE - reviewModeEditButton.visibility = View.GONE - questionnaireTitle.visibility = View.GONE - - // Set bottom navigation - if (state.bottomNavItem != null) { - bottomNavContainerFrame.visibility = View.VISIBLE - NavigationViewHolder(bottomNavContainerFrame) - .bind(state.bottomNavItem.questionnaireNavigationUIState) - } else { - bottomNavContainerFrame.visibility = View.GONE + // Hide progress indicator + questionnaireProgressIndicator.visibility = View.GONE } - - // Set progress indicator - questionnaireProgressIndicator.visibility = View.VISIBLE - if (displayMode.pagination.isPaginated) { - questionnaireProgressIndicator.updateProgressIndicator( - calculateProgressPercentage( - count = - (displayMode.pagination.currentPageIndex + - 1), // incremented by 1 due to initialPageIndex starts with 0. - totalCount = displayMode.pagination.pages.size, - ), - ) - } else { - questionnaireEditRecyclerView.addOnScrollListener( - object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) + is DisplayMode.EditMode -> { + // Set items + questionnaireReviewComposeView.visibility = View.GONE + questionnaireEditComposeView.setContent { + QuestionnaireEditList( + items = state.items, + displayMode = displayMode, + questionnaireItemViewHolderMatchers = + questionnaireItemViewHolderFactoryMatchersProvider.get(), + onUpdateProgressIndicator = { currentPage, totalCount -> questionnaireProgressIndicator.updateProgressIndicator( calculateProgressPercentage( - count = - (linearLayoutManager.findLastVisibleItemPosition() + - 1), // incremented by 1 due to findLastVisiblePosition() starts with 0. - totalCount = linearLayoutManager.itemCount, + count = (currentPage + 1), + totalCount = totalCount, ), ) - } - }, - ) + }, + ) + } + questionnaireEditComposeView.visibility = View.VISIBLE + reviewModeEditButton.visibility = View.GONE + questionnaireTitle.visibility = View.GONE + + // Set bottom navigation + if (state.bottomNavItem != null) { + bottomNavContainerFrame.visibility = View.VISIBLE + NavigationViewHolder(bottomNavContainerFrame) + .bind(state.bottomNavItem.questionnaireNavigationUIState) + } else { + bottomNavContainerFrame.visibility = View.GONE + } + + // Set progress indicator + questionnaireProgressIndicator.visibility = View.VISIBLE + if (displayMode.pagination.isPaginated) { + questionnaireProgressIndicator.updateProgressIndicator( + calculateProgressPercentage( + count = + (displayMode.pagination.currentPageIndex + + 1), // incremented by 1 due to initialPageIndex starts with 0. + totalCount = displayMode.pagination.pages.size, + ), + ) + } + } + is DisplayMode.InitMode -> { + questionnaireReviewComposeView.visibility = View.GONE + questionnaireEditComposeView.visibility = View.GONE + questionnaireProgressIndicator.visibility = View.GONE + reviewModeEditButton.visibility = View.GONE + bottomNavContainerFrame.visibility = View.GONE } - } - is DisplayMode.InitMode -> { - questionnaireReviewComposeView.visibility = View.GONE - questionnaireEditRecyclerView.visibility = View.GONE - questionnaireProgressIndicator.visibility = View.GONE - reviewModeEditButton.visibility = View.GONE - bottomNavContainerFrame.visibility = View.GONE } } } @@ -288,53 +277,6 @@ class QuestionnaireFragment : Fragment() { } } - @Composable - private fun QuestionnaireReviewList(items: List) { - LazyColumn { - items( - items = items, - key = { item -> - when (item) { - is QuestionnaireAdapterItem.Question -> item.id - ?: throw IllegalStateException("Missing id for the Question: $item") - is QuestionnaireAdapterItem.RepeatedGroupHeader -> item.id - is QuestionnaireAdapterItem.Navigation -> "navigation" - is QuestionnaireAdapterItem.RepeatedGroupAddButton -> item.id - ?: throw IllegalStateException("Missing id for the RepeatedGroupAddButton: $item") - } - }, - ) { item: QuestionnaireAdapterItem -> - AndroidView( - factory = { context -> - LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - when (item) { - is QuestionnaireAdapterItem.Question -> { - val viewHolder = ReviewViewHolderFactory.create(this) - viewHolder.bind(item.item) - addView(viewHolder.itemView) - } - is QuestionnaireAdapterItem.Navigation -> { - val viewHolder = - NavigationViewHolder(inflate(R.layout.pagination_navigation_view)) - viewHolder.bind(item.questionnaireNavigationUIState) - addView(viewHolder.itemView) - } - is QuestionnaireAdapterItem.RepeatedGroupHeader -> { - TODO("Not implemented yet") - } - is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { - TODO("Not implemented yet") - } - } - } - }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - /** Calculates the progress percentage from given [count] and [totalCount] values. */ internal fun calculateProgressPercentage(count: Int, totalCount: Int): Int { return if (totalCount == 0) 0 else (count * 100 / totalCount) @@ -589,6 +531,9 @@ class QuestionnaireFragment : Fragment() { */ internal const val EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON = "show-submit-anyway-button" + /** Test tag for QuestionnaireEditList */ + const val QUESTIONNAIRE_EDIT_LIST = "questionnaire_edit_list" + fun builder() = Builder() } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireLists.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireLists.kt new file mode 100644 index 0000000000..544aef30fc --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireLists.kt @@ -0,0 +1,375 @@ +/* + * Copyright 2024-2025 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.datacapture + +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.ViewCompat +import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.QUESTIONNAIRE_EDIT_LIST +import com.google.android.fhir.datacapture.contrib.views.PhoneNumberViewHolderFactory +import com.google.android.fhir.datacapture.extensions.inflate +import com.google.android.fhir.datacapture.extensions.itemControl +import com.google.android.fhir.datacapture.extensions.shouldUseDialog +import com.google.android.fhir.datacapture.views.NavigationViewHolder +import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +import com.google.android.fhir.datacapture.views.RepeatedGroupAddItemViewHolder +import com.google.android.fhir.datacapture.views.factories.AttachmentViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.AutoCompleteViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.BooleanChoiceViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.CheckBoxGroupViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.DatePickerViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.DateTimePickerViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.DisplayViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.DropDownViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.EditTextDecimalViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.EditTextIntegerViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.EditTextMultiLineViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.EditTextSingleLineViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.GroupViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuantityViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemDialogSelectViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.RadioGroupViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.RepeatedGroupHeaderItemViewHolder +import com.google.android.fhir.datacapture.views.factories.ReviewViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.SliderViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.TimePickerViewHolderFactory +import kotlin.uuid.ExperimentalUuidApi +import org.hl7.fhir.r4.model.Questionnaire + +// Choice questions are rendered as dialogs if they have at least this many options +const val MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DIALOG = 10 + +// Choice questions are rendered as radio group if number of options less than this constant +const val MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN = 4 + +@OptIn(ExperimentalUuidApi::class) +@Composable +internal fun QuestionnaireEditList( + items: List, + displayMode: DisplayMode, + questionnaireItemViewHolderMatchers: + List, + onUpdateProgressIndicator: (Int, Int) -> Unit, +) { + val listState = rememberLazyListState() + LaunchedEffect(listState) { + if (displayMode is DisplayMode.EditMode && !displayMode.pagination.isPaginated) { + snapshotFlow { + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val total = layoutInfo.totalItemsCount + + // If all items are visible, we're at 100% + if (visibleItems.size >= total && total > 0) { + total to total + } else { + lastVisible + 1 to total + } + } + .collect { (visibleCount, total) -> onUpdateProgressIndicator(visibleCount, total) } + } + } + LazyColumn(state = listState, modifier = Modifier.testTag(QUESTIONNAIRE_EDIT_LIST)) { + items( + items = items, + key = { item -> + when (item) { + is QuestionnaireAdapterItem.Question -> item.id + ?: throw IllegalStateException("Missing id for the Question: $item") + is QuestionnaireAdapterItem.RepeatedGroupHeader -> item.id + is QuestionnaireAdapterItem.Navigation -> "navigation" + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> item.id + ?: throw IllegalStateException("Missing id for the RepeatedGroupAddButton: $item") + } + }, + ) { adapterItem: QuestionnaireAdapterItem -> + AndroidView( + factory = { context -> + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + ViewCompat.setNestedScrollingEnabled(this, false) + } + }, + modifier = Modifier.fillMaxWidth(), + update = { view -> + val existingViewHolder = view.getTag(R.id.question_view_holder) + + val createViews = + when { + existingViewHolder == null -> true + adapterItem is QuestionnaireAdapterItem.Question && + existingViewHolder !is QuestionnaireItemViewHolder -> true + adapterItem is QuestionnaireAdapterItem.Navigation && + existingViewHolder !is NavigationViewHolder -> true + adapterItem is QuestionnaireAdapterItem.RepeatedGroupHeader && + existingViewHolder !is RepeatedGroupHeaderItemViewHolder -> true + adapterItem is QuestionnaireAdapterItem.RepeatedGroupAddButton && + existingViewHolder !is RepeatedGroupAddItemViewHolder -> true + else -> false + } + + if (createViews) { + view.removeAllViews() + when (adapterItem) { + is QuestionnaireAdapterItem.Question -> { + val viewHolder = + getQuestionnaireItemViewHolder( + parent = view, + questionnaireViewItem = adapterItem.item, + questionnaireItemViewHolderMatchers = questionnaireItemViewHolderMatchers, + ) + view.setTag(R.id.question_view_holder, viewHolder) + view.addView(viewHolder.itemView) + viewHolder.bind(adapterItem.item) + } + is QuestionnaireAdapterItem.Navigation -> { + val viewHolder = + NavigationViewHolder(view.inflate(R.layout.pagination_navigation_view)) + view.setTag(R.id.question_view_holder, viewHolder) + view.addView(viewHolder.itemView) + viewHolder.bind(adapterItem.questionnaireNavigationUIState) + } + is QuestionnaireAdapterItem.RepeatedGroupHeader -> { + val viewHolder = + RepeatedGroupHeaderItemViewHolder( + view.inflate(R.layout.repeated_group_instance_header_view), + ) + view.setTag(R.id.question_view_holder, viewHolder) + view.addView(viewHolder.itemView) + viewHolder.bind(adapterItem) + } + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { + val viewHolder = + RepeatedGroupAddItemViewHolder( + view.inflate(R.layout.add_repeated_item), + ) + view.setTag(R.id.question_view_holder, viewHolder) + view.addView(viewHolder.itemView) + viewHolder.bind(adapterItem.item) + } + } + } else { + // Update existing view holder + when (adapterItem) { + is QuestionnaireAdapterItem.Question -> { + (existingViewHolder as QuestionnaireItemViewHolder).bind(adapterItem.item) + } + is QuestionnaireAdapterItem.Navigation -> { + (existingViewHolder as NavigationViewHolder).bind( + adapterItem.questionnaireNavigationUIState, + ) + } + is QuestionnaireAdapterItem.RepeatedGroupHeader -> { + (existingViewHolder as RepeatedGroupHeaderItemViewHolder).bind(adapterItem) + } + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { + (existingViewHolder as RepeatedGroupAddItemViewHolder).bind(adapterItem.item) + } + } + } + }, + onReset = { view -> view.setTag(R.id.question_view_holder, null) }, + ) + } + } +} + +@Composable +internal fun QuestionnaireReviewList(items: List) { + LazyColumn { + items( + items = items, + key = { item -> + when (item) { + is QuestionnaireAdapterItem.Question -> item.id + ?: throw IllegalStateException("Missing id for the Question: $item") + is QuestionnaireAdapterItem.RepeatedGroupHeader -> item.id + is QuestionnaireAdapterItem.Navigation -> "navigation" + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> item.id + ?: throw IllegalStateException("Missing id for the RepeatedGroupAddButton: $item") + } + }, + ) { item: QuestionnaireAdapterItem -> + AndroidView( + factory = { context -> + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + when (item) { + is QuestionnaireAdapterItem.Question -> { + val viewHolder = ReviewViewHolderFactory.create(this) + viewHolder.bind(item.item) + addView(viewHolder.itemView) + } + is QuestionnaireAdapterItem.Navigation -> { + val viewHolder = NavigationViewHolder(inflate(R.layout.pagination_navigation_view)) + viewHolder.bind(item.questionnaireNavigationUIState) + addView(viewHolder.itemView) + } + is QuestionnaireAdapterItem.RepeatedGroupHeader -> { + TODO("Not implemented yet") + } + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { + TODO("Not implemented yet") + } + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +private fun getQuestionnaireItemViewHolder( + parent: ViewGroup, + questionnaireViewItem: QuestionnaireViewItem, + questionnaireItemViewHolderMatchers: + List, +): QuestionnaireItemViewHolder { + // Find a matching custom widget + val questionnaireViewHolderFactory = + questionnaireItemViewHolderMatchers + .find { it.matches(questionnaireViewItem.questionnaireItem) } + ?.factory + ?: getQuestionnaireItemViewHolderFactory(getItemViewTypeForQuestion(questionnaireViewItem)) + return questionnaireViewHolderFactory.create(parent) +} + +private fun getQuestionnaireItemViewHolderFactory( + questionnaireViewHolderType: QuestionnaireViewHolderType, +): QuestionnaireItemViewHolderFactory { + val viewHolderFactory = + when (questionnaireViewHolderType) { + QuestionnaireViewHolderType.GROUP -> GroupViewHolderFactory + QuestionnaireViewHolderType.BOOLEAN_TYPE_PICKER -> BooleanChoiceViewHolderFactory + QuestionnaireViewHolderType.DATE_PICKER -> DatePickerViewHolderFactory + QuestionnaireViewHolderType.TIME_PICKER -> TimePickerViewHolderFactory + QuestionnaireViewHolderType.DATE_TIME_PICKER -> DateTimePickerViewHolderFactory + QuestionnaireViewHolderType.EDIT_TEXT_SINGLE_LINE -> EditTextSingleLineViewHolderFactory + QuestionnaireViewHolderType.EDIT_TEXT_MULTI_LINE -> EditTextMultiLineViewHolderFactory + QuestionnaireViewHolderType.EDIT_TEXT_INTEGER -> EditTextIntegerViewHolderFactory + QuestionnaireViewHolderType.EDIT_TEXT_DECIMAL -> EditTextDecimalViewHolderFactory + QuestionnaireViewHolderType.RADIO_GROUP -> RadioGroupViewHolderFactory + QuestionnaireViewHolderType.DROP_DOWN -> DropDownViewHolderFactory + QuestionnaireViewHolderType.DISPLAY -> DisplayViewHolderFactory + QuestionnaireViewHolderType.QUANTITY -> QuantityViewHolderFactory + QuestionnaireViewHolderType.CHECK_BOX_GROUP -> CheckBoxGroupViewHolderFactory + QuestionnaireViewHolderType.AUTO_COMPLETE -> AutoCompleteViewHolderFactory + QuestionnaireViewHolderType.DIALOG_SELECT -> QuestionnaireItemDialogSelectViewHolderFactory + QuestionnaireViewHolderType.SLIDER -> SliderViewHolderFactory + QuestionnaireViewHolderType.PHONE_NUMBER -> PhoneNumberViewHolderFactory + QuestionnaireViewHolderType.ATTACHMENT -> AttachmentViewHolderFactory + } + return viewHolderFactory +} + +/** + * Returns the [QuestionnaireViewHolderType] that will be used to render the + * [QuestionnaireViewItem]. This is determined by a combination of the data type of the question and + * any additional Questionnaire Item UI Control Codes + * (http://hl7.org/fhir/R4/valueset-questionnaire-item-control.html) used in the itemControl + * extension (http://hl7.org/fhir/R4/extension-questionnaire-itemcontrol.html). + */ +private fun getItemViewTypeForQuestion( + questionnaireViewItem: QuestionnaireViewItem, +): QuestionnaireViewHolderType { + val questionnaireItem = questionnaireViewItem.questionnaireItem + + if (questionnaireViewItem.enabledAnswerOptions.isNotEmpty()) { + return getChoiceViewHolderType(questionnaireViewItem) + } + + return when (val type = questionnaireItem.type) { + Questionnaire.QuestionnaireItemType.GROUP -> QuestionnaireViewHolderType.GROUP + Questionnaire.QuestionnaireItemType.BOOLEAN -> QuestionnaireViewHolderType.BOOLEAN_TYPE_PICKER + Questionnaire.QuestionnaireItemType.DATE -> QuestionnaireViewHolderType.DATE_PICKER + Questionnaire.QuestionnaireItemType.TIME -> QuestionnaireViewHolderType.TIME_PICKER + Questionnaire.QuestionnaireItemType.DATETIME -> QuestionnaireViewHolderType.DATE_TIME_PICKER + Questionnaire.QuestionnaireItemType.STRING -> getStringViewHolderType(questionnaireViewItem) + Questionnaire.QuestionnaireItemType.TEXT -> QuestionnaireViewHolderType.EDIT_TEXT_MULTI_LINE + Questionnaire.QuestionnaireItemType.INTEGER -> getIntegerViewHolderType(questionnaireViewItem) + Questionnaire.QuestionnaireItemType.DECIMAL -> QuestionnaireViewHolderType.EDIT_TEXT_DECIMAL + Questionnaire.QuestionnaireItemType.CHOICE, + Questionnaire.QuestionnaireItemType.REFERENCE, -> getChoiceViewHolderType(questionnaireViewItem) + Questionnaire.QuestionnaireItemType.DISPLAY -> QuestionnaireViewHolderType.DISPLAY + Questionnaire.QuestionnaireItemType.QUANTITY -> QuestionnaireViewHolderType.QUANTITY + Questionnaire.QuestionnaireItemType.ATTACHMENT -> QuestionnaireViewHolderType.ATTACHMENT + else -> throw NotImplementedError("Question type $type not supported.") + } +} + +private fun getChoiceViewHolderType( + questionnaireViewItem: QuestionnaireViewItem, +): QuestionnaireViewHolderType { + val questionnaireItem = questionnaireViewItem.questionnaireItem + + // Use the view type that the client wants if they specified an itemControl or dialog extension + return when { + questionnaireItem.shouldUseDialog -> QuestionnaireViewHolderType.DIALOG_SELECT + else -> questionnaireItem.itemControl?.viewHolderType + } + // Otherwise, choose a sensible UI element automatically + ?: run { + val numOptions = questionnaireViewItem.enabledAnswerOptions.size + when { + // Always use a dialog for questions with a large number of options + numOptions >= MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DIALOG -> + QuestionnaireViewHolderType.DIALOG_SELECT + + // Use a check box group if repeated answers are permitted + questionnaireItem.repeats -> QuestionnaireViewHolderType.CHECK_BOX_GROUP + + // Use a dropdown if there are a medium number of options + numOptions >= MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN -> + QuestionnaireViewHolderType.DROP_DOWN + + // Use a radio group only if there are a small number of options + else -> QuestionnaireViewHolderType.RADIO_GROUP + } + } +} + +private fun getIntegerViewHolderType( + questionnaireViewItem: QuestionnaireViewItem, +): QuestionnaireViewHolderType { + val questionnaireItem = questionnaireViewItem.questionnaireItem + // Use the view type that the client wants if they specified an itemControl + return questionnaireItem.itemControl?.viewHolderType + ?: QuestionnaireViewHolderType.EDIT_TEXT_INTEGER +} + +private fun getStringViewHolderType( + questionnaireViewItem: QuestionnaireViewItem, +): QuestionnaireViewHolderType { + val questionnaireItem = questionnaireViewItem.questionnaireItem + // Use the view type that the client wants if they specified an itemControl + return questionnaireItem.itemControl?.viewHolderType + ?: QuestionnaireViewHolderType.EDIT_TEXT_SINGLE_LINE +} diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderType.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderType.kt index d9442a652a..76c683f6eb 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderType.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderType.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023-2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,8 +19,8 @@ package com.google.android.fhir.datacapture /** * Questionnaire item view holder types supported by default by the data capture library. * - * This is used in [QuestionnaireEditAdapter] to determine how each [Questionnaire.Item] is - * rendered. + * This is used by the [QuestionnaireFragment] lists to determine how each + * [org.hl7.fhir.r4.model.Questionnaire.item] is rendered. * * This list should provide sufficient coverage for values in * https://www.hl7.org/fhir/valueset-item-type.html and diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt index 7d7d0e935a..0161b32a8e 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt @@ -862,10 +862,11 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat private suspend fun getQuestionnaireAdapterItems( questionnaireItemList: List, questionnaireResponseItemList: List, + parentIdPrefix: String = "", ): List { return questionnaireItemList .zipByLinkId(questionnaireResponseItemList) { questionnaireItem, questionnaireResponseItem -> - getQuestionnaireAdapterItems(questionnaireItem, questionnaireResponseItem) + getQuestionnaireAdapterItems(questionnaireItem, questionnaireResponseItem, parentIdPrefix) } .flatten() } @@ -877,6 +878,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat private suspend fun getQuestionnaireAdapterItems( questionnaireItem: QuestionnaireItemComponent, questionnaireResponseItem: QuestionnaireResponseItemComponent, + parentIdPrefix: String = "", ): List { // Hidden questions should not get QuestionnaireItemViewItem instances if (questionnaireItem.isHidden) return emptyList() @@ -937,49 +939,54 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat val question = QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = validationResult, - answersChangedCallback = answersChangedCallback, - enabledAnswerOptions = enabledQuestionnaireAnswerOptions, - minAnswerValue = - questionnaireItem.minValueCqfCalculatedValueExpression?.let { - expressionEvaluator.evaluateExpressionValue( - questionnaireItem, - questionnaireResponseItem, - it, - ) - } - ?: questionnaireItem.minValue, - maxAnswerValue = - questionnaireItem.maxValueCqfCalculatedValueExpression?.let { - expressionEvaluator.evaluateExpressionValue( - questionnaireItem, - questionnaireResponseItem, - it, - ) - } - ?: questionnaireItem.maxValue, - draftAnswer = draftAnswerMap[questionnaireResponseItem], - enabledDisplayItems = - questionnaireItem.item.filter { - it.isDisplayItem && - enablementEvaluator.evaluate( + QuestionnaireViewItem( + questionnaireItem, + questionnaireResponseItem, + validationResult = validationResult, + answersChangedCallback = answersChangedCallback, + enabledAnswerOptions = enabledQuestionnaireAnswerOptions, + minAnswerValue = + questionnaireItem.minValueCqfCalculatedValueExpression?.let { + expressionEvaluator.evaluateExpressionValue( + questionnaireItem, + questionnaireResponseItem, it, + ) + } + ?: questionnaireItem.minValue, + maxAnswerValue = + questionnaireItem.maxValueCqfCalculatedValueExpression?.let { + expressionEvaluator.evaluateExpressionValue( + questionnaireItem, questionnaireResponseItem, + it, ) - }, - questionViewTextConfiguration = - QuestionTextConfiguration( - showAsterisk = showAsterisk, - showRequiredText = showRequiredText, - showOptionalText = showOptionalText, - ), - isHelpCardOpen = isHelpCard && isHelpCardOpen, - helpCardStateChangedCallback = helpCardStateChangedCallback, - ), - ) + } + ?: questionnaireItem.maxValue, + draftAnswer = draftAnswerMap[questionnaireResponseItem], + enabledDisplayItems = + questionnaireItem.item.filter { + it.isDisplayItem && + enablementEvaluator.evaluate( + it, + questionnaireResponseItem, + ) + }, + questionViewTextConfiguration = + QuestionTextConfiguration( + showAsterisk = showAsterisk, + showRequiredText = showRequiredText, + showOptionalText = showOptionalText, + ), + isHelpCardOpen = isHelpCard && isHelpCardOpen, + helpCardStateChangedCallback = helpCardStateChangedCallback, + ), + ) + .apply { + if (parentIdPrefix.isNotEmpty()) { + id = "${parentIdPrefix}${questionnaireItem.linkId}" + } + } add(question) // Add nested questions after the parent item. We need to get the questionnaire items and @@ -995,20 +1002,41 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat // view model, we create dummy answers for each repeated group. As a result the processing of // this case is similar to the case of questions nested under a question. // For background, see https://build.fhir.org/questionnaireresponse.html#link. - buildList { - // Case 1 - if (!questionnaireItem.isRepeatedGroup) { - add(questionnaireResponseItem.item) - } - // Case 2 and 3 - addAll(questionnaireResponseItem.answer.map { it.item }) - } + + // Case 1: Non-repeated group - process nested items directly with current prefix + if (!questionnaireItem.isRepeatedGroup && questionnaireResponseItem.item.isNotEmpty()) { + addAll( + getQuestionnaireAdapterItems( + questionnaireItemList = questionnaireItem.item.filterNot { it.isDisplayItem }, + questionnaireResponseItemList = questionnaireResponseItem.item, + parentIdPrefix = parentIdPrefix, + ), + ) + } + + // Case 2 and 3: Questions nested under answers (for questions with nested items or repeated + // groups) + questionnaireResponseItem.answer + .map { it.item } .forEachIndexed { index, nestedResponseItemList -> + val currentIdPrefix = + if (!questionnaireItem.isRepeatedGroup) { + // Case 2: Questions nested under a question (not a repeated group) + if (parentIdPrefix.isEmpty()) { + "${index}_${question.item.questionnaireItem.linkId}_" + } else { + "${parentIdPrefix}${index}_${question.item.questionnaireItem.linkId}_" + } + } else { + // Case 3: Build hierarchical ID prefix for nested repeated groups + "${parentIdPrefix}${index}_${question.item.questionnaireItem.linkId}_" + } + if (questionnaireItem.isRepeatedGroup) { // Case 3 add( QuestionnaireAdapterItem.RepeatedGroupHeader( - id = "${index}_${question.item.questionnaireItem.linkId}", + id = "${parentIdPrefix}${index}_${question.item.questionnaireItem.linkId}", index = index, onDeleteClicked = { viewModelScope.launch { question.item.removeAnswerAt(index) } }, responses = nestedResponseItemList, @@ -1018,28 +1046,19 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } addAll( getQuestionnaireAdapterItems( - // If nested display item is identified as instructions or flyover, then do not - // create - // questionnaire state for it. - questionnaireItemList = questionnaireItem.item.filterNot { it.isDisplayItem }, - questionnaireResponseItemList = nestedResponseItemList, - ) - .onEach { - // Reset the question id to avoid duplicate keys in LazyColumn composable. The new - // id is derived from the the repeated group index, the parent question - // questionnaire item linkId and the linkId of the nested questions - if (it is QuestionnaireAdapterItem.Question) { - it.id = - "${index}_${question.item.questionnaireItem.linkId}_${it.item.questionnaireItem.linkId}" - } - }, + // If nested display item is identified as instructions or flyover, then do not + // create questionnaire state for it. + questionnaireItemList = questionnaireItem.item.filterNot { it.isDisplayItem }, + questionnaireResponseItemList = nestedResponseItemList, + parentIdPrefix = currentIdPrefix, + ), ) } if (questionnaireItem.isRepeatedGroup) { add( QuestionnaireAdapterItem.RepeatedGroupAddButton( - id = "${question.item.questionnaireItem.linkId}_add_btn", + id = "${parentIdPrefix}${question.item.questionnaireItem.linkId}_add_btn", item = question.item, ), ) diff --git a/datacapture/src/main/res/layout/questionnaire_fragment.xml b/datacapture/src/main/res/layout/questionnaire_fragment.xml index 73e65c380a..75acda11ac 100644 --- a/datacapture/src/main/res/layout/questionnaire_fragment.xml +++ b/datacapture/src/main/res/layout/questionnaire_fragment.xml @@ -60,14 +60,14 @@ style="?attr/questionnaireLinearProgressIndicatorStyle" android:layout_width="match_parent" android:layout_height="wrap_content" - app:layout_constraintBottom_toTopOf="@id/questionnaire_edit_recycler_view" + app:layout_constraintBottom_toTopOf="@id/questionnaire_edit_compose_view" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/questionnaire_title_layout" /> - + + + diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapterTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapterTest.kt deleted file mode 100644 index 7f90968396..0000000000 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapterTest.kt +++ /dev/null @@ -1,900 +0,0 @@ -/* - * 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. - * 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.datacapture - -import android.os.Build -import android.widget.FrameLayout -import androidx.test.core.app.ApplicationProvider -import com.google.android.fhir.datacapture.extensions.EXTENSION_DIALOG_URL_ANDROID_FHIR -import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM -import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM_ANDROID_FHIR -import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL -import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL_ANDROID_FHIR -import com.google.android.fhir.datacapture.extensions.ItemControlTypes -import com.google.android.fhir.datacapture.validation.Invalid -import com.google.android.fhir.datacapture.validation.NotValidated -import com.google.android.fhir.datacapture.views.MediaView -import com.google.android.fhir.datacapture.views.QuestionnaireViewItem -import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder -import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderFactory -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.test.runTest -import org.hl7.fhir.r4.model.CodeableConcept -import org.hl7.fhir.r4.model.Coding -import org.hl7.fhir.r4.model.DateType -import org.hl7.fhir.r4.model.Extension -import org.hl7.fhir.r4.model.IntegerType -import org.hl7.fhir.r4.model.Questionnaire -import org.hl7.fhir.r4.model.QuestionnaireResponse -import org.hl7.fhir.r4.model.StringType -import org.junit.Assert.assertThrows -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.P]) -class QuestionnaireEditAdapterTest { - @Test - fun getItemViewType_groupItemType_shouldReturnGroupViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.GROUP), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.GROUP.value) - } - - @Test - fun getItemViewType_booleanItemType_shouldReturnBooleanViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.BOOLEAN), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.BOOLEAN_TYPE_PICKER.value) - } - - @Test - fun getItemViewType_dateItemType_shouldReturnDatePickerViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.DATE), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.DATE_PICKER.value) - } - - @Test - fun getItemViewType_dateItemType_answerOption_shouldReturnDropDownViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItemComponent = - Questionnaire.QuestionnaireItemComponent().apply { - type = Questionnaire.QuestionnaireItemType.DATE - answerOption = - listOf(Questionnaire.QuestionnaireItemAnswerOptionComponent(DateType("2022-06-22"))) - } - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItemComponent, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.RADIO_GROUP.value) - } - - @Test - fun getItemViewType_dateTimeItemType_shouldReturnDateTimePickerViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.DATETIME), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.DATE_TIME_PICKER.value) - } - - @Test - fun getItemViewType_stringItemType_shouldReturnEditTextViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.STRING), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.EDIT_TEXT_SINGLE_LINE.value) - } - - @Suppress("ktlint:standard:max-line-length") - @Test - fun getItemViewType_stringItemType_androidItemControlExtension_shouldReturnPhoneNumberViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent().setType(Questionnaire.QuestionnaireItemType.STRING) - questionnaireItem.addExtension( - Extension() - .setUrl(EXTENSION_ITEM_CONTROL_URL_ANDROID_FHIR) - .setValue( - CodeableConcept() - .addCoding( - Coding() - .setCode(ItemControlTypes.PHONE_NUMBER.extensionCode) - .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM_ANDROID_FHIR), - ), - ), - ) - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.PHONE_NUMBER.value) - } - - @Test - fun getItemViewType_stringItemType_answerOption_shouldReturnDropDownViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItemComponent = - Questionnaire.QuestionnaireItemComponent().apply { - type = Questionnaire.QuestionnaireItemType.STRING - answerOption = - listOf(Questionnaire.QuestionnaireItemAnswerOptionComponent(StringType("option-1"))) - } - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItemComponent, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.RADIO_GROUP.value) - } - - @Test - fun getItemViewType_textItemType_shouldReturnEditTextViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.TEXT), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.EDIT_TEXT_MULTI_LINE.value) - } - - @Test - fun getItemViewType_integerItemType_shouldReturnEditTextIntegerViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.INTEGER), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.EDIT_TEXT_INTEGER.value) - } - - @Test - fun getItemViewType_integerItemType_itemControlExtensionWithSlider_shouldReturnSliderViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.INTEGER) - questionnaireItem.addExtension( - Extension() - .setUrl(EXTENSION_ITEM_CONTROL_URL) - .setValue( - CodeableConcept() - .addCoding( - Coding() - .setCode(ItemControlTypes.SLIDER.extensionCode) - .setDisplay("Slider") - .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM), - ), - ), - ) - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.SLIDER.value) - } - - @Test - fun getItemViewType_integerItemType_answerOption_shouldReturnDropDownViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItemComponent = - Questionnaire.QuestionnaireItemComponent().apply { - type = Questionnaire.QuestionnaireItemType.INTEGER - answerOption = - listOf(Questionnaire.QuestionnaireItemAnswerOptionComponent(IntegerType("1"))) - } - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItemComponent, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.RADIO_GROUP.value) - } - - @Test - fun getItemViewType_decimalItemType_shouldReturnEditTextDecimalViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.DECIMAL), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.EDIT_TEXT_DECIMAL.value) - } - - @Test - fun getItemViewType_choiceItemType_lessAnswerOptions_shouldReturnRadioGroupViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.CHOICE), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.RADIO_GROUP.value) - } - - @Test - fun getItemViewType_choiceItemType_moreAnswerOptions_shouldReturnDropDownViewHolderType() { - val answerOptions = - List(QuestionnaireEditAdapter.MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN) { - Questionnaire.QuestionnaireItemAnswerOptionComponent() - .setValue(Coding().setCode("test-code").setDisplay("Test Code")) - } - val questionnaireEditAdapter = QuestionnaireEditAdapter() - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.CHOICE) - .setAnswerOption(answerOptions), - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.DROP_DOWN.value) - } - - @Suppress("ktlint:standard:max-line-length") - @Test - fun getItemViewType_choiceItemType_itemControlExtensionWithRadioButton_shouldReturnRadioGroupViewHolder() { - val answerOptions = - List(QuestionnaireEditAdapter.MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN) { - Questionnaire.QuestionnaireItemAnswerOptionComponent() - .setValue(Coding().setCode("test-code").setDisplay("Test Code")) - } - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent() - .setType(Questionnaire.QuestionnaireItemType.CHOICE) - .setAnswerOption(answerOptions) - questionnaireItem.addExtension( - Extension() - .setUrl(EXTENSION_ITEM_CONTROL_URL) - .setValue( - CodeableConcept() - .addCoding( - Coding() - .setCode(ItemControlTypes.RADIO_BUTTON.extensionCode) - .setDisplay("Radio Button") - .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM), - ), - ), - ) - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.RADIO_GROUP.value) - } - - @Suppress("ktlint:standard:max-line-length") - @Test - fun getItemViewType_choiceItemType_itemControlExtensionWithDropDown_shouldReturnDropDownViewHolderType() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent().setType(Questionnaire.QuestionnaireItemType.CHOICE) - questionnaireItem.addExtension( - Extension() - .setUrl(EXTENSION_ITEM_CONTROL_URL) - .setValue( - CodeableConcept() - .addCoding( - Coding() - .setCode(ItemControlTypes.DROP_DOWN.extensionCode) - .setDisplay("Drop Down") - .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM), - ), - ), - ) - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.DROP_DOWN.value) - } - - @Test - fun `getItemViewType() with radio button and dialog extension should return dialog select view holder type`() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent().setType(Questionnaire.QuestionnaireItemType.CHOICE) - questionnaireItem.apply { - addExtension( - Extension() - .setUrl(EXTENSION_ITEM_CONTROL_URL) - .setValue( - CodeableConcept() - .addCoding( - Coding() - .setCode(ItemControlTypes.RADIO_BUTTON.extensionCode) - .setDisplay("Radio Button") - .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM), - ), - ), - ) - addExtension(Extension().setUrl(EXTENSION_DIALOG_URL_ANDROID_FHIR)) - } - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.DIALOG_SELECT.value) - } - - @Test - fun `getItemViewType() with check box and dialog extension should return dialog select view holder type`() { - val questionnaireEditAdapter = QuestionnaireEditAdapter() - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent().setType(Questionnaire.QuestionnaireItemType.CHOICE) - questionnaireItem.apply { - addExtension( - Extension() - .setUrl(EXTENSION_ITEM_CONTROL_URL) - .setValue( - CodeableConcept() - .addCoding( - Coding() - .setCode(ItemControlTypes.CHECK_BOX.extensionCode) - .setDisplay("Check Box") - .setSystem(EXTENSION_ITEM_CONTROL_SYSTEM), - ), - ), - ) - addExtension(Extension().setUrl(EXTENSION_DIALOG_URL_ANDROID_FHIR)) - } - questionnaireEditAdapter.submitList( - listOf( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - - assertThat(questionnaireEditAdapter.getItemViewType(0)) - .isEqualTo(QuestionnaireViewHolderType.DIALOG_SELECT.value) - } - - // TODO: test errors thrown for unsupported types - - @Test - fun `areItemsTheSame() should return false if the questionnaire items are different`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val otherQuestionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areItemsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - otherQuestionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isFalse() - } - - fun `areItemsTheSame() should return false if the questionnaire response items are different`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - val otherQuestionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areItemsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - otherQuestionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isFalse() - } - - fun `areItemsTheSame() should return true if the questionnaire item and the questionnaire response item are the same`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areItemsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isTrue() - } - - @Test - fun `areContentsTheSame() should return false if the questionnaire items are different`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val otherQuestionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areContentsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - otherQuestionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isFalse() - } - - fun `areContentsTheSame() should return false if the questionnaire response items are different`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - val otherQuestionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areContentsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - otherQuestionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isFalse() - } - - fun `areContentsTheSame() should return false if the answers are different`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - runTest { - assertThat( - DiffCallbacks.ITEMS.areContentsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ) - .apply { - addAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = StringType("answer") - }, - ) - }, - ), - ), - ) - .isFalse() - } - } - - fun `areContentsTheSame() should return false if the validation results are different`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areContentsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = Invalid(listOf()), - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isFalse() - } - - fun `areContentsTheSame() should treat not validated and invalid validation results as the same`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areContentsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isTrue() - } - - fun `areContentsTheSame() should return true if the questionnaire, the questionnaire response, the answers, and the validation results are all the same`() { - val questionnaireItem = Questionnaire.QuestionnaireItemComponent() - val questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent() - - assertThat( - DiffCallbacks.ITEMS.areContentsTheSame( - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - QuestionnaireAdapterItem.Question( - QuestionnaireViewItem( - questionnaireItem, - questionnaireResponseItem, - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ), - ), - ), - ) - .isTrue() - } - - @Test - fun onCreateViewHolder_customViewType_shouldReturnCorrectCustomViewHolder() { - val viewFactoryMatchers = getQuestionnaireItemViewHolderFactoryMatchers() - val questionnaireEditAdapter = QuestionnaireEditAdapter(viewFactoryMatchers) - val holder = - questionnaireEditAdapter.onCreateViewHolder(mock(), QuestionnaireViewHolderType.values().size) - holder as QuestionnaireEditAdapter.ViewHolder.QuestionHolder - assertThat(holder.holder).isEqualTo(fakeHolder) - } - - @Test - fun onCreateViewHolder_customViewType_shouldThrowExceptionForInvalidWidgetType() { - val viewFactoryMatchers = getQuestionnaireItemViewHolderFactoryMatchers() - val questionnaireEditAdapter = QuestionnaireEditAdapter(viewFactoryMatchers) - assertThrows(IllegalStateException::class.java) { - QuestionnaireEditAdapter(getQuestionnaireItemViewHolderFactoryMatchers()) - questionnaireEditAdapter.onCreateViewHolder( - mock(), - QuestionnaireViewHolderType.values().size + viewFactoryMatchers.size, - ) - } - } - - @Test - fun getItemViewTypeMapping_customViewType_shouldReturnCorrectIntValue() { - val expectedItemViewType = QuestionnaireViewHolderType.values().size - val questionnaireViewItem = - QuestionnaireViewItem( - Questionnaire.QuestionnaireItemComponent().apply { - type = Questionnaire.QuestionnaireItemType.DATE - }, - QuestionnaireResponse.QuestionnaireResponseItemComponent(), - validationResult = NotValidated, - answersChangedCallback = { _, _, _, _ -> }, - ) - - assertThat(expectedItemViewType) - .isEqualTo( - QuestionnaireEditAdapter(getQuestionnaireItemViewHolderFactoryMatchers()) - .getItemViewTypeForQuestion(questionnaireViewItem), - ) - } - - private fun getQuestionnaireItemViewHolderFactoryMatchers(): - List { - return listOf( - QuestionnaireFragment.QuestionnaireItemViewHolderFactoryMatcher( - mock().apply { - whenever(create(any())).thenReturn(fakeHolder) - }, - ) { questionnaireItem -> - questionnaireItem.type == Questionnaire.QuestionnaireItemType.DATE - }, - ) - } - - private val fakeHolder = - QuestionnaireItemViewHolder( - itemView = - FrameLayout(ApplicationProvider.getApplicationContext()).apply { - addView(MediaView(context, null).apply { id = R.id.item_media }) - }, - delegate = mock(), - ) -}