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 3ca8eb1b21..e2f04eab28 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 @@ -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,22 +19,20 @@ package com.google.android.fhir.datacapture.test import android.view.View import android.widget.FrameLayout import android.widget.TextView +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.printToLog 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.action.ViewActions.typeText import androidx.test.espresso.assertion.ViewAssertions -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 @@ -73,10 +71,7 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class QuestionnaireUiEspressoTest { - @Rule - @JvmField - var activityScenarioRule: ActivityScenarioRule = - ActivityScenarioRule(TestActivity::class.java) + @get:Rule(order = 9) val composeTestRule = createAndroidComposeRule() private lateinit var parent: FrameLayout private val parser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() @@ -84,14 +79,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), @@ -99,13 +94,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), @@ -119,7 +114,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)), ) @@ -129,7 +124,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), @@ -141,8 +136,9 @@ class QuestionnaireUiEspressoTest { fun integerTextEdit_inputOutOfRange_shouldShowError() { buildFragmentFromQuestionnaire("/text_questionnaire_integer.json") - onView(withId(R.id.text_input_edit_text)).perform(typeText("12345678901")) - onView(withId(R.id.text_input_layout)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) + .perform(typeText("12345678901")) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)).check { view, _ -> val actualError = (view as TextInputLayout).error assertThat(actualError).isEqualTo("Number must be between -2,147,483,648 and 2,147,483,647") } @@ -156,15 +152,18 @@ class QuestionnaireUiEspressoTest { buildFragmentFromQuestionnaire("/text_questionnaire_integer.json") runTest { - onView(withId(R.id.text_input_edit_text)).perform(typeText("0")) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) + .perform(typeText("0")) assertThat(getQuestionnaireResponse().item.first().answer.first().valueIntegerType.value) .isEqualTo(0) - onView(withId(R.id.text_input_edit_text)).perform(typeText("01")) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) + .perform(typeText("01")) assertThat(getQuestionnaireResponse().item.first().answer.first().valueIntegerType.value) .isEqualTo(1) - 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()).isEqualTo("001") } @@ -178,15 +177,18 @@ class QuestionnaireUiEspressoTest { buildFragmentFromQuestionnaire("/text_questionnaire_decimal.json") runTest { - onView(withId(R.id.text_input_edit_text)).perform(typeText("0.")) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) + .perform(typeText("0.")) assertThat(getQuestionnaireResponse().item.first().answer.first().valueDecimalType.value) .isEqualTo(BigDecimal.valueOf(0.0)) - onView(withId(R.id.text_input_edit_text)).perform(typeText("01")) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) + .perform(typeText("01")) assertThat(getQuestionnaireResponse().item.first().answer.first().valueDecimalType.value) .isEqualTo(BigDecimal.valueOf(0.01)) - 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()).isEqualTo("0.01") } @@ -199,9 +201,10 @@ class QuestionnaireUiEspressoTest { fun decimalTextEdit_typingInvalidTextShouldShowError() { buildFragmentFromQuestionnaire("/text_questionnaire_decimal.json") - onView(withId(R.id.text_input_edit_text)).perform(typeText("1.1.1.1")) + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_edit_text)) + .perform(typeText("1.1.1.1")) - onView(withId(R.id.text_input_layout)).check { view, _ -> + onView(withId(com.google.android.fhir.datacapture.R.id.text_input_layout)).check { view, _ -> assertThat((view as TextInputLayout).error).isEqualTo("Invalid number") } } @@ -211,31 +214,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() + } runTest { assertThat(getQuestionnaireResponse().item.size).isEqualTo(1) @@ -247,11 +254,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") @@ -269,11 +277,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)") } @@ -283,11 +291,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) } @@ -322,8 +330,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()) @@ -369,8 +378,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()) @@ -416,8 +426,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()) @@ -468,7 +479,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()) @@ -480,32 +491,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) } @@ -516,7 +530,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, _ -> @@ -530,7 +544,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) } } @@ -539,13 +553,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() } } @@ -554,88 +568,87 @@ 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_repeated_group_is_added() { buildFragmentFromQuestionnaire("/component_repeated_group.json") + composeTestRule.onRoot().printToLog("ComposableHierarchy") + onView(withId(com.google.android.fhir.datacapture.R.id.add_item)).perform(ViewActions.click()) - onView(withId(R.id.questionnaire_edit_recycler_view)) - .perform( - RecyclerViewActions.actionOnItemAtPosition( - 0, - clickChildViewWithId(R.id.add_item), - ), - ) + 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 @@ -645,51 +658,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), - ), - ) + 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(0) - } - } - - 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(ViewAssertions.doesNotExist()) + } private fun buildFragmentFromQuestionnaire( fileName: String, @@ -706,7 +688,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) @@ -724,7 +706,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) @@ -737,7 +719,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/QuestionnaireAdapterItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt index 5b97c29b62..6dbe5a2d10 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireAdapterItem.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 Google LLC + * 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. @@ -22,10 +22,13 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse /** Various types of rows that can be used in a Questionnaire RecyclerView. */ internal sealed interface QuestionnaireAdapterItem { /** A row for a question in a Questionnaire RecyclerView. */ - data class Question(val item: QuestionnaireViewItem) : QuestionnaireAdapterItem + data class Question(val item: QuestionnaireViewItem) : QuestionnaireAdapterItem { + var id: String? = item.questionnaireItem.linkId + } /** A row for a repeated group response instance's header. */ data class RepeatedGroupHeader( + val id: String, /** The response index. This is 0-indexed, but should be 1-indexed when rendered in the UI. */ val index: Int, /** Callback that is invoked when the user clicks the delete button. */ diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditItem.kt new file mode 100644 index 0000000000..682c5c5527 --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditItem.kt @@ -0,0 +1,173 @@ +/* + * 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 com.google.android.fhir.datacapture.QuestionnaireEditAdapter.Companion.MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DIALOG +import com.google.android.fhir.datacapture.QuestionnaireEditAdapter.Companion.MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN +import com.google.android.fhir.datacapture.contrib.views.PhoneNumberViewHolderFactory +import com.google.android.fhir.datacapture.extensions.itemControl +import com.google.android.fhir.datacapture.extensions.shouldUseDialog +import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +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.SliderViewHolderFactory +import com.google.android.fhir.datacapture.views.factories.TimePickerViewHolderFactory +import org.hl7.fhir.r4.model.Questionnaire + +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/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt index 590339b5e7..7610d9a48a 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,11 +21,27 @@ 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.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.res.use import androidx.core.os.bundleOf +import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.setFragmentResult @@ -33,10 +49,14 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.google.android.fhir.datacapture.extensions.inflate import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.views.NavigationViewHolder +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.RepeatedGroupHeaderItemViewHolder import com.google.android.material.progressindicator.LinearProgressIndicator +import kotlin.uuid.ExperimentalUuidApi import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Questionnaire import timber.log.Timber @@ -91,8 +111,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 questionnaireReviewRecyclerView = view.findViewById(R.id.questionnaire_review_recycler_view) val questionnaireTitle = view.findViewById(R.id.questionnaire_title) @@ -137,8 +157,7 @@ class QuestionnaireFragment : Fragment() { } val questionnaireProgressIndicator: LinearProgressIndicator = view.findViewById(R.id.questionnaire_progress_indicator) - val questionnaireEditAdapter = - QuestionnaireEditAdapter(questionnaireItemViewHolderFactoryMatchersProvider.get()) + val questionnaireReviewAdapter = QuestionnaireReviewAdapter() val reviewModeEditButton = @@ -146,12 +165,6 @@ class QuestionnaireFragment : Fragment() { 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 - questionnaireReviewRecyclerView.adapter = questionnaireReviewAdapter questionnaireReviewRecyclerView.layoutManager = LinearLayoutManager(view.context) @@ -161,7 +174,7 @@ class QuestionnaireFragment : Fragment() { when (val displayMode = state.displayMode) { is DisplayMode.ReviewMode -> { // Set items - questionnaireEditRecyclerView.visibility = View.GONE + questionnaireEditComposeView.visibility = View.GONE questionnaireReviewAdapter.submitList( state.items, ) @@ -190,8 +203,21 @@ class QuestionnaireFragment : Fragment() { is DisplayMode.EditMode -> { // Set items questionnaireReviewRecyclerView.visibility = View.GONE - questionnaireEditAdapter.submitList(state.items) - questionnaireEditRecyclerView.visibility = View.VISIBLE + questionnaireEditComposeView.setContent { + val questionerStateFlow = viewModel.questionnaireStateFlow.collectAsState() + QuestionnaireEditList( + questionerStateFlow, + onUpdateProgressIndicator = { currentPage, totalCount -> + questionnaireProgressIndicator.updateProgressIndicator( + calculateProgressPercentage( + count = (currentPage + 1), + totalCount = totalCount, + ), + ) + }, + ) + } + questionnaireEditComposeView.visibility = View.VISIBLE reviewModeEditButton.visibility = View.GONE questionnaireTitle.visibility = View.GONE @@ -215,27 +241,11 @@ class QuestionnaireFragment : Fragment() { 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) - questionnaireProgressIndicator.updateProgressIndicator( - calculateProgressPercentage( - count = - (linearLayoutManager.findLastVisibleItemPosition() + - 1), // incremented by 1 due to findLastVisiblePosition() starts with 0. - totalCount = linearLayoutManager.itemCount, - ), - ) - } - }, - ) } } is DisplayMode.InitMode -> { questionnaireReviewRecyclerView.visibility = View.GONE - questionnaireEditRecyclerView.visibility = View.GONE + questionnaireEditComposeView.visibility = View.GONE questionnaireProgressIndicator.visibility = View.GONE reviewModeEditButton.visibility = View.GONE bottomNavContainerFrame.visibility = View.GONE @@ -283,6 +293,108 @@ class QuestionnaireFragment : Fragment() { } } + @OptIn(ExperimentalUuidApi::class) + @Composable + private fun QuestionnaireEditList( + questionerStateFlow: State, + onUpdateProgressIndicator: (Int, Int) -> Unit, + ) { + val listState = rememberLazyListState() + val currentDisplayMode = remember { questionerStateFlow.value.displayMode } + + LaunchedEffect(listState) { + if ( + currentDisplayMode is DisplayMode.EditMode && !currentDisplayMode.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( + questionerStateFlow.value.items, + key = { item -> + when (item) { + is QuestionnaireAdapterItem.Question -> item.id + ?: throw IllegalStateException("Missing id for the QuestionnaireAdapterItem: $item") + is QuestionnaireAdapterItem.RepeatedGroupHeader -> item.id + is QuestionnaireAdapterItem.Navigation -> "navigation" + } + }, + ) { adapterItem: QuestionnaireAdapterItem -> + AndroidView( + factory = { context -> + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + ViewCompat.setNestedScrollingEnabled(this, false) + // Build the view using viewHolder factories. To keep the viewHolder accessible + // across recompositions, each created view is tagged with its viewHolder. + // On recomposition, the views are not recreated—instead, their content is + // refreshed by calling viewHolder#bind. + when (adapterItem) { + is QuestionnaireAdapterItem.Question -> { + val viewHolder = + getQuestionnaireItemViewHolder( + parent = this, + questionnaireViewItem = adapterItem.item, + questionnaireItemViewHolderMatchers = + questionnaireItemViewHolderFactoryMatchersProvider.get(), + ) + viewHolder.bind(adapterItem.item) + setTag(R.id.question_view_holder, viewHolder) + addView(viewHolder.itemView) + } + is QuestionnaireAdapterItem.Navigation -> { + val viewHolder = + NavigationViewHolder(inflate(R.layout.pagination_navigation_view)) + viewHolder.bind(adapterItem.questionnaireNavigationUIState) + setTag(R.id.question_view_holder, viewHolder) + addView(viewHolder.itemView) + } + is QuestionnaireAdapterItem.RepeatedGroupHeader -> { + val viewHolder = + RepeatedGroupHeaderItemViewHolder( + inflate(R.layout.repeated_group_instance_header_view), + ) + viewHolder.bind(adapterItem) + setTag(R.id.question_view_holder, viewHolder) + addView(viewHolder.itemView) + } + } + } + }, + modifier = Modifier.fillMaxWidth(), + update = { view -> + val viewHolderTag = view.getTag(R.id.question_view_holder) + when (viewHolderTag) { + is QuestionnaireItemViewHolder -> + viewHolderTag.bind((adapterItem as QuestionnaireAdapterItem.Question).item) + is NavigationViewHolder -> + viewHolderTag.bind( + (adapterItem as QuestionnaireAdapterItem.Navigation) + .questionnaireNavigationUIState, + ) + is RepeatedGroupHeaderItemViewHolder -> + viewHolderTag.bind((adapterItem as QuestionnaireAdapterItem.RepeatedGroupHeader)) + } + }, + ) + } + } + } + /** 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) @@ -537,6 +649,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/QuestionnaireViewModel.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt index e96bd28a50..4ff6af22f0 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 @@ -1008,6 +1008,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat // Case 3 add( QuestionnaireAdapterItem.RepeatedGroupHeader( + id = "${index}_${question.item.questionnaireItem.linkId}", index = index, onDeleteClicked = { viewModelScope.launch { question.item.removeAnswerAt(index) } }, responses = nestedResponseItemList, @@ -1017,11 +1018,21 @@ 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, - ), + // 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}" + } + }, ) } } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt index d9025fd284..8a9a85f020 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.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. diff --git a/datacapture/src/main/res/layout/questionnaire_fragment.xml b/datacapture/src/main/res/layout/questionnaire_fragment.xml index 986e51e74d..5597433bb6 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" /> - + + +